[Web] Update composer libraries

This commit is contained in:
FreddleSpl0it committed 2026-10-28 11:56:29 +01:00
1 parent 9cc8ac2908
commit bd8c784118
1297 files changed
+71585 -24460

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
# Agent Guidelines
## Code and tooling
- All code must remain compatible with PHP 7.4. Run PHPStan and PHP-CS-Fixer
only under a PHP 7.4.x runtime; never run either tool under any other PHP
major.minor version.
- Use fully qualified `#[\SensitiveParameter]` on concrete executable
parameters when their established role normally carries a secret,
credential-bearing aggregate, or confidential Guzzle-owned container, and
the active frame can throw or invoke throwing code.
- Repeat the attribute on every qualifying owned caller, callee, concrete trait
method, and closure parameter. Do not add it to interfaces, abstract-only
declarations, pure/no-realistic-throw helpers, assignment-only sites,
arbitrary generic payloads, or completed non-recoverable derivatives.
- For PHP 7.4 compatibility, put `#[\SensitiveParameter]` on its own line and
the parameter on the following line, expand the complete parameter list, and
never add a comma after the final parameter. Native trace redaction starts on
PHP 8.2 and does not redact logs, messages, properties, wire traffic, captured
variables, return values, or the separate backtrace `$this`/`object`.
- Always pass an explicit character list to `trim()`, `ltrim()`, and `rtrim()`;
never rely on the default characters.
- Handle `preg_*` engine failures: when the result is used as data, test for
`false` or `null` and throw a `\RuntimeException` including
`preg_last_error_msg()`; boolean validation guards must compare strictly, such
as `=== 1`, so an engine failure can only ever fail closed. Diagnostic
escaping is the narrow exception: use a deterministic bytewise fallback rather
than throwing, so it cannot obscure the original exception.
- Anchor validation patterns to the true end of input with the `D` modifier or
`\z`; a bare `$` accepts a trailing newline.
- Never embed raw control bytes in exception messages and other diagnostics;
escape or redact the offending value first.
- Helper classes that expose only public static methods are `final` and have a
private constructor.
- Resist native PHP serialization when a class holds live state (streams,
resources, handles, callbacks, credentials) or when magic methods such as
`__destruct()` have side effects that untrusted unserialized data could
redirect, as with the file and session write fixes in Guzzle's persisting
cookie jars. Plain data holders, such as Guzzle's in-memory cookie jar, remain
serializable.
- To resist, `__serialize()` and `__unserialize()` both throw
`\LogicException(static::class.' should never be serialized')` and its
unserialized counterpart, usually via the `@internal`
`NonSerializableStreamTrait`. Where a `__destruct()` has such a side effect,
it is enabled only by the constructor and disabled in `__wakeup()` and
`__unserialize()` before throwing, so the protection holds even if the
exception is swallowed.
- In general, numeric inputs should not accept non-finite floats. In situations
where they are accepted and we need to cast to a string, we should branch on
`\is_finite($value)`, using `(string) $value` for the finite case and
`\is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF')` otherwise.
- Never call `strtolower()`, `strtoupper()`, `strcasecmp()`, `stripos()`, or
other locale-sensitive case functions; use the locale-independent
`Utils::asciiToLower()`, `Utils::asciiToUpper()`, `Utils::caselessEquals()`,
and `Utils::caselessContains()` helpers instead.
- Changes in behavior need a `CHANGELOG.md` entry in the unreleased section of
the target branch and an `UPGRADING.md` note when the behavior differs between
major versions.
## Documentation
- Wrap markdown prose and PHPDoc text to 80 columns using greedy wrapping. Never
split a markdown link or an inline code span across a line break; a line that
cannot be broken may exceed the limit. Avoid em dashes.
- Keep PHPDoc and the corresponding `docs/` pages in sync: shared prose is
deliberately word-for-word identical, including boilerplate copied verbatim
between related functions, so apply the same edit to every copy. Only
formatting and linking may differ, such as a docs link becoming a PHPDoc
`@see` tag; the wording must never drift.
+369 -1
View File
@@ -5,7 +5,375 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## 3.2.0 - 2026-10-18
### Fixed
- Fix `InflateStream` ignoring source read failures that follow decoded bytes
- Fix `AppendStream` discarding bytes read before a timeout
## 3.1.1 - 2026-10-18
### Fixed
- Fix `StreamWrapper` flushes reporting failure
- Fix `CachingStream` skipping unread bytes after repeated writes
## 3.1.0 - 2026-08-24
### Added
- Add `Utils::redactUriForMessage()` and `Utils::redactUriStringForMessage()` for URI diagnostics
- Add support for PHP 8.6
### Changed
- Omit rejected header values and sensitive URI components from automatic exception messages
## 3.0.1 - 2026-08-24
### Fixed
- Prefix relative paths that begin with a colon segment with `./` instead of throwing
- Apply the `/.` prefix for authority-less `//` paths to percent-encoding normalizations as well
- Keep colon-leading first path segments when reading the paths of scheme-less non-native URIs
- Stop throwing when removing the default `file` host strands a `//` path, prefixing it with `/.`
## 3.0.0 - 2026-07-20
### Added
- Add `DiagnosticValue::escape()` to escape controls and malformed UTF-8 in diagnostics
- Add `GuzzleHttp\Psr7\Exception\TimeoutException` for timed-out stream operations
- Add `GuzzleHttp\Psr7\Utils::redactUserInfoInString()` to redact the userinfo of a raw URI string within text
- Promote `GuzzleHttp\Psr7\Rfc3986` to public API with `isValid*()` predicates and `canonicalizeIpv6()`
- Add `GuzzleHttp\Psr7\UriNormalizer::CANONICALIZE_IPV6_HOST` to `PRESERVING_NORMALIZATIONS`
### Changed
- Require `psr/http-message:^2.0` and add native parameter and return types
- Require `psr/http-factory:^1.1`
- Reject native PHP serialization of stream implementations
- Preserve request method casing, except `ServerRequest::fromGlobals()` still uppercases
- Reject empty arrays and non-string values as header values
- Reject invalid uploaded file trees and invalid parsed body values
- Reject uploaded file specs missing `tmp_name`, `size`, or `error`
- Reject non-integer and negative uploaded file `error` values
- Reject invalid stream/upload sizes, buffer high-water marks, and dropping-stream limits
- Rewind seekable uploaded-file streams before copying in `UploadedFile::moveTo()`
- Reject negative `read()` lengths across all stream implementations
- Detect the `+` flag anywhere in a mode for `Stream::isReadable()`/`isWritable()`
- Reject empty strings returned by `PumpStream` source callables
- Discard buffered bytes on `PumpStream` close and detach
- Restore the original stream position after `Message::bodySummary()`
- Allow `null` for the `Message::bodySummary()` truncation length to use the default
- Validate `LimitStream` offset/limit and track non-seekable offset by bytes skipped
- Make `FnStream` close and detach terminal, calling close callbacks at most once
- Suppress exceptions from `FnStream` close callbacks during destructor cleanup
- Make `CachingStream::close()` idempotent, preserving remote cleanup after detach
- Do not move the `CachingStream` cursor when a `SEEK_END` target on an unknown-size stream is rejected
- Normalize multiple leading slashes in `Uri::getPath()` and origin-form request targets
- Serialize authority-less `file` URIs with rootless paths without the `//` separator
- Serialize authority-less `file` URIs with empty paths as `file:` instead of the unparseable `file://`
- Remove dot segments above the root per RFC 3986, prefixing authority-less `//` paths with `/.`
- Return a network-path reference from `UriResolver::relativize()` when an empty-path target requires one
- Stop returning an empty reference from `UriResolver::relativize()` when it would inherit the base fragment
- Stop throwing from `UriResolver::relativize()` when an equal-path target's last path segment contains a colon
- Harden URI host validation (delimiters, backslashes, IPv6, embedded ports); require schemes to start with a letter
- Validate `Uri::fromParts()` ports instead of casting them
- Treat ports 80 and 443 as defaults for the `ws` and `wss` schemes
- Use the `ws` and `wss` default ports in `UriComparator::isCrossOrigin()` port comparisons
- Redact all non-empty URI userinfo in `Utils::redactUserInfo()`
- Rebuild server request URIs from `$_SERVER` by `REQUEST_METHOD`, using target authority before `SERVER_PORT`
- Remove userinfo from absolute-form `REQUEST_URI` targets in `ServerRequest::fromGlobals()`
- Reject zero-port `HTTP_HOST` and malformed `SERVER_PORT` in `ServerRequest::getUriFromGlobals()`
- Reject malformed `REQUEST_METHOD` and `SERVER_PROTOCOL` server values in `ServerRequest::fromGlobals()`
- Reject zero-port `Host` and normalize leading-zero ports in `Message::parseRequest()`
- Reject duplicate `Host` headers and validate present values for all request-target forms
- Reject zero-port, hostless, and userinfo absolute-form request targets in `Message::parseRequest()`
- Synchronize the `Host` header in `Request::withUri()` when the URI changes or Host is empty
- Include the URI port in `Host` headers synthesized by `Message::toString()`
- Validate the Host header synthesized by `Message::toString()` from the request URI
- Include non-default URI ports in `Host` headers set by `Utils::modifyRequest()` URI changes
- Accept `OPTIONS *` and `CONNECT` authority-form request targets in `Message::parseRequest()`
- Hide credential-bearing URI, server, and cookie arguments in stack traces on PHP 8.2+
- Reject malformed HTTP request/response start-lines
- Unfold obsolete HTTP/1.0 line folding for all valid request method tokens
- Reject empty and control-character request targets in `Request::withRequestTarget()`
- Validate iterator chunks passed to `Utils::streamFor()`
- Validate unsupported values passed to `Query::build()`
- Reject non-finite float values in `Query::build()` and `MultipartStream` contents
- Reject non-finite float values in iterator chunks passed to `Utils::streamFor()`
- `Utils::streamFor()` now rejects non-string scalar bodies
- `Uri::withQueryValues()` now rejects non-string values
- Reject invalid `Utils::modifyRequest()` change values
- Use PHP debug type names in type error messages
- Changed `Utils::copyToStream()` to throw when destination streams cannot make progress
- Throw `TimeoutException` from `Stream` read/write and `Utils` copy/hash/readLine on stream timeouts
- Throw `TimeoutException` from `AppendStream::read()`, `CachingStream::read()`, and `Utils::tryGetContents()` on stream timeouts
- Re-throw `TimeoutException` from `InflateStream` when the decoded source stream times out
- Close the compressed source stream from `InflateStream::close()`
- Return the number of bytes copied from `Utils::copyToStream()`
- Throw `OverflowException` when stream byte counts or offsets exceed `PHP_INT_MAX`
- Translate `StreamWrapper` runtime failures to PHP stream failure values
- Stop adding default `Content-Length` to `multipart/form-data` parts (RFC 7578 §4.8)
- Escape multipart `Content-Disposition` parameters and reject unsafe boundaries and part headers
- Preserve trailing whitespace in custom `MultipartStream` part header values
- Preserve explicit custom `MultipartStream` boundary `'0'` instead of replacing it with a generated boundary
- Made static utility classes non-instantiable
- Validate bracketed IP-literal hosts consistently between parsing and `withHost()`, including userinfo forms
- Percent-encode raw control bytes in userinfo before bracketed IP-literal hosts instead of parsing mutated values
- Reject invalid UTF-8 and preserve percent-sequences in userinfo before bracketed IP-literal hosts
- Normalize percent-encoded octets in the URI host to uppercase hex
- Canonicalize IPv6 hosts to RFC 5952 form in `Uri` construction, `fromParts()`, and `withHost()`
- Canonicalize bracketed IPv6 hosts in `UriComparator::isCrossOrigin()`
- Reject malformed percent-sequences and percent-encoded bytes forbidden by the URI host policy
- Extend `CAPITALIZE_PERCENT_ENCODING` and `DECODE_UNRESERVED_CHARACTERS` to userinfo and host
- Trim header list elements with only spaces, horizontal tabs, and line terminators in `Header::splitList()`
- Report header parameter PCRE failures explicitly in `Header::parse()`
- Escape controls and malformed UTF-8 consistently in generated exception messages
### Removed
- Dropped support for PHP 7.2 and 7.3
- Removed the `ralouphie/getallheaders` dependency
- Removed deprecated `Header::normalize()` method
## 2.13.0 - 2026-07-16
### Added
- Add `Utils::` `asciiToLower`, `asciiToUpper`, `asciiUcFirst`, `caselessEquals`, `caselessContains`
### Changed
- Use locale-independent ASCII case folding everywhere case is normalized
- Trigger a runtime deprecation for previously deprecated functionality in 2.3.0
## 2.12.5 - 2026-07-13
### Fixed
- Compare header names and hosts with locale-independent ASCII lowercasing
- Compare hosts without locale sensitivity when detecting cross-origin redirects
## 2.12.4 - 2026-07-08
### Changed
- Pass explicit trim characters ahead of the PHP 8.6 trim default change
### Fixed
- Anchor server port and response start-line patterns to the true end of input
- Treat host-less origin-form request targets starting with `//` as paths in `Message::parseRequest()`
- Reject raw DEL bytes in bracketed IP-literal hosts instead of parsing a mutated host
- Reject invalid bytes after a bracketed IP-literal host instead of reparsing a different host
## 2.12.3 - 2026-06-23
### Security
- Validate the URI host so `getHost()` matches the URI authority (GHSA-c2w2-prh8-qm98)
## 2.12.2 - 2026-06-23
### Fixed
- Report URI parsing, filtering, and normalization PCRE failures explicitly
- Report HTTP message parser PCRE failures explicitly
- Fail closed when PCRE validation fails for request targets and hosts
## 2.12.1 - 2026-06-18
### Security
- Reject CR/LF in HTTP method, protocol version, and reason phrase (GHSA-vm85-hxw5-5432)
## 2.12.0 - 2026-06-16
### Deprecated
- Deprecated non-finite float values in `Query::build()` that guzzlehttp/psr7 3.0 rejects
- Deprecated non-finite float multipart contents that guzzlehttp/psr7 3.0 rejects
- Deprecated non-string scalar bodies in `Utils::streamFor()`; cast them to a string for 3.0
- Deprecated non-string `Uri::withQueryValues()` values; cast them to a string for 3.0
## 2.11.1 - 2026-06-12
### Fixed
- Fixed non-finite float values emitting coercion warnings on PHP 8.5
## 2.11.0 - 2026-06-02
### Changed
- Changed `Utils::modifyRequest()` to reject conflicting URI and `Host` header changes in the same call
- Changed `Header::parse()` to split semicolon-separated parameters without repeated regular expression lookaheads
- Changed `UriComparator::isCrossOrigin()` so only HTTP and HTTPS missing ports receive implicit default ports
### Deprecated
- Deprecated invalid PSR-7 arguments that guzzlehttp/psr7 3.0 will require native types for
- Deprecated non-string header values that guzzlehttp/psr7 3.0 will reject
- Deprecated empty header value arrays that guzzlehttp/psr7 3.0 will reject
- Deprecated URI schemes that do not match guzzlehttp/psr7 3.0 syntax requirements
- Deprecated multipart boundary and custom part header metadata that guzzlehttp/psr7 3.0 will reject
- Deprecated reliance on automatic uppercasing of request methods; guzzlehttp/psr7 3.0 preserves method casing
- Deprecated invalid `Utils::modifyRequest()` change values that guzzlehttp/psr7 3.0 will reject
### Fixed
- Fixed `Utils::copyToStream()` to retry short destination writes instead of dropping the unwritten remainder
- Fixed `Header::parse()` splitting of semicolon-separated parameters with escaped quotes
## 2.10.4 - 2026-05-29
### Fixed
- Apply `UriNormalizer` percent-encoding normalizations to URI fragments
- Make `LimitStream::getSize()` return `0` for slices past the underlying stream end
- Make `AppendStream::read()` return an empty string when no streams are attached
- Make `CachingStream::read()` throw on an incomplete cache-target write instead of silently corrupting replays
- Prevent `CachingStream::seek()` from looping indefinitely when the remote stream makes no progress
## 2.10.3 - 2026-05-27
### Fixed
- Fixed URI parsing for IPv6 literals containing embedded IPv4 addresses
- Fixed malformed UTF-8 URI strings being parsed as empty URIs
## 2.10.2 - 2026-05-25
### Security
- Reject control and whitespace characters in URI host components (GHSA-hq7v-mx3g-29hw)
- Reject malformed Host values when constructing request URIs (GHSA-34xg-wgjx-8xph)
### Fixed
- Make `ServerRequest::fromGlobals()` robust against unexpected HTTP header value types in `$_SERVER`
## 2.10.1 - 2026-05-20
### Fixed
- Fix `Utils::modifyRequest()` with numeric header names
## 2.10.0 - 2026-05-19
### Changed
- Harden `ServerRequest::fromGlobals()` against malformed `$_SERVER` values
- Prevent custom stream metadata from affecting internal size handling
- Throw when `StreamWrapper::getResource()` cannot create a resource
- Preserve custom request implementations in `Utils::modifyRequest()`
- Preserve custom URI implementations in `UriResolver::resolve()`
- Make `Uri::__toString()` side-effect-free
## 2.9.1 - 2026-05-19
### Fixed
- Fix parsing of relative path references containing a colon in a non-initial path segment
- Fix `CachingStream::detach()` returning an incomplete resource before the decorated stream has been fully read
- Fix `Message::bodySummary()` returning `null` when truncating printable UTF-8 bodies inside a multibyte character
## 2.9.0 - 2026-03-10
### Added
- Added nested array expansion support to `MultipartStream`
- Added `@return static` to `MessageTrait` methods
### Changed
- Updated MIME type mappings
## 2.8.1 - 2026-03-10
### Fixed
- Encode `+` signs in `Uri::withQueryValue()` and `Uri::withQueryValues()` to prevent them being interpreted as spaces
## 2.8.0 - 2025-08-23
### Added
- Allow empty lists as header values
### Changed
- PHP 8.5 support
## 2.7.1 - 2025-03-27
### Fixed
- Fixed uppercase IPv6 addresses in URI
### Changed
- Improve uploaded file error message
## 2.7.0 - 2024-07-18
### Added
- Add `Utils::redactUserInfo()` method
- Add ability to encode bools as ints in `Query::build`
## 2.6.3 - 2024-07-18
### Fixed
- Make `StreamWrapper::stream_stat()` return `false` if inner stream's size is `null`
### Changed
- PHP 8.4 support
## 2.6.2 - 2023-12-03
### Fixed
- Fixed another issue with the fact that PHP transforms numeric strings in array keys to ints
### Changed
- Updated links in docs to their canonical versions
- Replaced `call_user_func*` with native calls
## 2.6.1 - 2023-08-27
### Fixed
- Properly handle the fact that PHP transforms numeric strings in array keys to ints
## 2.6.0 - 2023-08-03
### Changed
- Updated the mime type map to add some new entries, fix a couple of invalid entries, and remove an invalid entry
- Fallback to `application/octet-stream` if we are unable to guess the content type for a multipart file upload
## 2.5.1 - 2023-08-03
### Fixed
- Corrected mime type for `.acc` files to `audio/aac`
### Changed
- PHP 8.3 support
## 2.5.0 - 2023-04-17
### Changed
- Adjusted `psr/http-message` version constraint to `^1.1 || ^2.0`
## 2.4.5 - 2023-04-17
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+66 -859
View File
@@ -1,880 +1,87 @@
# PSR-7 Message Implementation
# Guzzle PSR-7
This repository contains a full [PSR-7](https://www.php-fig.org/psr/psr-7/)
message implementation, several stream decorators, and some helpful
functionality like query string parsing.
`guzzlehttp/psr7` is a PSR-7 HTTP message implementation for PHP. It provides
request, response, URI, uploaded file, and stream objects that work with Guzzle
and any other library using the PSR-7 interfaces.
![CI](https://github.com/guzzle/psr7/workflows/CI/badge.svg)
![Static analysis](https://github.com/guzzle/psr7/workflows/Static%20analysis/badge.svg)
Use this package directly when you need to create or inspect PSR-7 messages
without sending HTTP requests. If you only want to make HTTP requests, install
[`guzzlehttp/guzzle`](https://github.com/guzzle/guzzle/blob/8.0/README.md)
instead; it already depends on this package.
## Installation
# Installation
```shell
```bash
composer require guzzlehttp/psr7
```
# Stream implementation
This package comes with a number of stream implementations and stream
decorators.
## AppendStream
`GuzzleHttp\Psr7\AppendStream`
Reads from multiple streams, one after the other.
```php
use GuzzleHttp\Psr7;
$a = Psr7\Utils::streamFor('abc, ');
$b = Psr7\Utils::streamFor('123.');
$composed = new Psr7\AppendStream([$a, $b]);
$composed->addStream(Psr7\Utils::streamFor(' Above all listen to me'));
echo $composed; // abc, 123. Above all listen to me.
```
## BufferStream
`GuzzleHttp\Psr7\BufferStream`
Provides a buffer stream that can be written to fill a buffer, and read
from to remove bytes from the buffer.
This stream returns a "hwm" metadata value that tells upstream consumers
what the configured high water mark of the stream is, or the maximum
preferred size of the buffer.
```php
use GuzzleHttp\Psr7;
// When more than 1024 bytes are in the buffer, it will begin returning
// false to writes. This is an indication that writers should slow down.
$buffer = new Psr7\BufferStream(1024);
```
## CachingStream
The CachingStream is used to allow seeking over previously read bytes on
non-seekable streams. This can be useful when transferring a non-seekable
entity body fails due to needing to rewind the stream (for example, resulting
from a redirect). Data that is read from the remote stream will be buffered in
a PHP temp stream so that previously read bytes are cached first in memory,
then on disk.
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor(fopen('http://www.google.com', 'r'));
$stream = new Psr7\CachingStream($original);
$stream->read(1024);
echo $stream->tell();
// 1024
$stream->seek(0);
echo $stream->tell();
// 0
```
## DroppingStream
`GuzzleHttp\Psr7\DroppingStream`
Stream decorator that begins dropping data once the size of the underlying
stream becomes too full.
```php
use GuzzleHttp\Psr7;
// Create an empty stream
$stream = Psr7\Utils::streamFor();
// Start dropping data when the stream has more than 10 bytes
$dropping = new Psr7\DroppingStream($stream, 10);
$dropping->write('01234567890123456789');
echo $stream; // 0123456789
```
## FnStream
`GuzzleHttp\Psr7\FnStream`
Compose stream implementations based on a hash of functions.
Allows for easy testing and extension of a provided stream without needing
to create a concrete class for a simple extension point.
```php
use GuzzleHttp\Psr7;
$stream = Psr7\Utils::streamFor('hi');
$fnStream = Psr7\FnStream::decorate($stream, [
'rewind' => function () use ($stream) {
echo 'About to rewind - ';
$stream->rewind();
echo 'rewound!';
}
]);
$fnStream->rewind();
// Outputs: About to rewind - rewound!
```
## InflateStream
`GuzzleHttp\Psr7\InflateStream`
Uses PHP's zlib.inflate filter to inflate zlib (HTTP deflate, RFC1950) or gzipped (RFC1952) content.
This stream decorator converts the provided stream to a PHP stream resource,
then appends the zlib.inflate filter. The stream is then converted back
to a Guzzle stream resource to be used as a Guzzle stream.
## LazyOpenStream
`GuzzleHttp\Psr7\LazyOpenStream`
Lazily reads or writes to a file that is opened only after an IO operation
take place on the stream.
```php
use GuzzleHttp\Psr7;
$stream = new Psr7\LazyOpenStream('/path/to/file', 'r');
// The file has not yet been opened...
echo $stream->read(10);
// The file is opened and read from only when needed.
```
## LimitStream
`GuzzleHttp\Psr7\LimitStream`
LimitStream can be used to read a subset or slice of an existing stream object.
This can be useful for breaking a large file into smaller pieces to be sent in
chunks (e.g. Amazon S3's multipart upload API).
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor(fopen('/tmp/test.txt', 'r+'));
echo $original->getSize();
// >>> 1048576
// Limit the size of the body to 1024 bytes and start reading from byte 2048
$stream = new Psr7\LimitStream($original, 1024, 2048);
echo $stream->getSize();
// >>> 1024
echo $stream->tell();
// >>> 0
```
## MultipartStream
`GuzzleHttp\Psr7\MultipartStream`
Stream that when read returns bytes for a streaming multipart or
multipart/form-data stream.
## NoSeekStream
`GuzzleHttp\Psr7\NoSeekStream`
NoSeekStream wraps a stream and does not allow seeking.
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor('foo');
$noSeek = new Psr7\NoSeekStream($original);
echo $noSeek->read(3);
// foo
var_export($noSeek->isSeekable());
// false
$noSeek->seek(0);
var_export($noSeek->read(3));
// NULL
```
## PumpStream
`GuzzleHttp\Psr7\PumpStream`
Provides a read only stream that pumps data from a PHP callable.
When invoking the provided callable, the PumpStream will pass the amount of
data requested to read to the callable. The callable can choose to ignore
this value and return fewer or more bytes than requested. Any extra data
returned by the provided callable is buffered internally until drained using
the read() function of the PumpStream. The provided callable MUST return
false when there is no more data to read.
## Implementing stream decorators
Creating a stream decorator is very easy thanks to the
`GuzzleHttp\Psr7\StreamDecoratorTrait`. This trait provides methods that
implement `Psr\Http\Message\StreamInterface` by proxying to an underlying
stream. Just `use` the `StreamDecoratorTrait` and implement your custom
methods.
For example, let's say we wanted to call a specific function each time the last
byte is read from a stream. This could be implemented by overriding the
`read()` method.
```php
use Psr\Http\Message\StreamInterface;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
class EofCallbackStream implements StreamInterface
{
use StreamDecoratorTrait;
private $callback;
private $stream;
public function __construct(StreamInterface $stream, callable $cb)
{
$this->stream = $stream;
$this->callback = $cb;
}
public function read($length)
{
$result = $this->stream->read($length);
// Invoke the callback when EOF is hit.
if ($this->eof()) {
call_user_func($this->callback);
}
return $result;
}
}
```
This decorator could be added to any existing stream and used like so:
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor('foo');
$eofStream = new EofCallbackStream($original, function () {
echo 'EOF!';
});
$eofStream->read(2);
$eofStream->read(1);
// echoes "EOF!"
$eofStream->seek(0);
$eofStream->read(3);
// echoes "EOF!"
```
## PHP StreamWrapper
You can use the `GuzzleHttp\Psr7\StreamWrapper` class if you need to use a
PSR-7 stream as a PHP stream resource.
Use the `GuzzleHttp\Psr7\StreamWrapper::getResource()` method to create a PHP
stream from a PSR-7 stream.
```php
use GuzzleHttp\Psr7\StreamWrapper;
$stream = GuzzleHttp\Psr7\Utils::streamFor('hello!');
$resource = StreamWrapper::getResource($stream);
echo fread($resource, 6); // outputs hello!
```
# Static API
There are various static methods available under the `GuzzleHttp\Psr7` namespace.
## `GuzzleHttp\Psr7\Message::toString`
`public static function toString(MessageInterface $message): string`
Returns the string representation of an HTTP message.
```php
$request = new GuzzleHttp\Psr7\Request('GET', 'http://example.com');
echo GuzzleHttp\Psr7\Message::toString($request);
```
## `GuzzleHttp\Psr7\Message::bodySummary`
`public static function bodySummary(MessageInterface $message, int $truncateAt = 120): string|null`
Get a short summary of the message body.
Will return `null` if the response is not printable.
## `GuzzleHttp\Psr7\Message::rewindBody`
`public static function rewindBody(MessageInterface $message): void`
Attempts to rewind a message body and throws an exception on failure.
The body of the message will only be rewound if a call to `tell()`
returns a value other than `0`.
## `GuzzleHttp\Psr7\Message::parseMessage`
`public static function parseMessage(string $message): array`
Parses an HTTP message into an associative array.
The array contains the "start-line" key containing the start line of
the message, "headers" key containing an associative array of header
array values, and a "body" key containing the body of the message.
## `GuzzleHttp\Psr7\Message::parseRequestUri`
`public static function parseRequestUri(string $path, array $headers): string`
Constructs a URI for an HTTP request message.
## `GuzzleHttp\Psr7\Message::parseRequest`
`public static function parseRequest(string $message): Request`
Parses a request message string into a request object.
## `GuzzleHttp\Psr7\Message::parseResponse`
`public static function parseResponse(string $message): Response`
Parses a response message string into a response object.
## `GuzzleHttp\Psr7\Header::parse`
`public static function parse(string|array $header): array`
Parse an array of header values containing ";" separated data into an
array of associative arrays representing the header key value pair data
of the header. When a parameter does not contain a value, but just
contains a key, this function will inject a key with a '' string value.
## `GuzzleHttp\Psr7\Header::splitList`
`public static function splitList(string|string[] $header): string[]`
Splits a HTTP header defined to contain a comma-separated list into
each individual value:
```
$knownEtags = Header::splitList($request->getHeader('if-none-match'));
```
Example headers include `accept`, `cache-control` and `if-none-match`.
## `GuzzleHttp\Psr7\Header::normalize` (deprecated)
`public static function normalize(string|array $header): array`
`Header::normalize()` is deprecated in favor of [`Header::splitList()`](README.md#guzzlehttppsr7headersplitlist)
which performs the same operation with a cleaned up API and improved
documentation.
Converts an array of header values that may contain comma separated
headers into an array of headers with no comma separated values.
## `GuzzleHttp\Psr7\Query::parse`
`public static function parse(string $str, int|bool $urlEncoding = true): array`
Parse a query string into an associative array.
If multiple values are found for the same key, the value of that key
value pair will become an array. This function does not parse nested
PHP style arrays into an associative array (e.g., `foo[a]=1&foo[b]=2`
will be parsed into `['foo[a]' => '1', 'foo[b]' => '2'])`.
## `GuzzleHttp\Psr7\Query::build`
`public static function build(array $params, int|false $encoding = PHP_QUERY_RFC3986): string`
Build a query string from an array of key value pairs.
This function can use the return value of `parse()` to build a query
string. This function does not modify the provided keys when an array is
encountered (like `http_build_query()` would).
## `GuzzleHttp\Psr7\Utils::caselessRemove`
`public static function caselessRemove(iterable<string> $keys, $keys, array $data): array`
Remove the items given by the keys, case insensitively from the data.
## `GuzzleHttp\Psr7\Utils::copyToStream`
`public static function copyToStream(StreamInterface $source, StreamInterface $dest, int $maxLen = -1): void`
Copy the contents of a stream into another stream until the given number
of bytes have been read.
## `GuzzleHttp\Psr7\Utils::copyToString`
`public static function copyToString(StreamInterface $stream, int $maxLen = -1): string`
Copy the contents of a stream into a string until the given number of
bytes have been read.
## `GuzzleHttp\Psr7\Utils::hash`
`public static function hash(StreamInterface $stream, string $algo, bool $rawOutput = false): string`
Calculate a hash of a stream.
This method reads the entire stream to calculate a rolling hash, based on
PHP's `hash_init` functions.
## `GuzzleHttp\Psr7\Utils::modifyRequest`
`public static function modifyRequest(RequestInterface $request, array $changes): RequestInterface`
Clone and modify a request with the given changes.
This method is useful for reducing the number of clones needed to mutate
a message.
- method: (string) Changes the HTTP method.
- set_headers: (array) Sets the given headers.
- remove_headers: (array) Remove the given headers.
- body: (mixed) Sets the given body.
- uri: (UriInterface) Set the URI.
- query: (string) Set the query string value of the URI.
- version: (string) Set the protocol version.
## `GuzzleHttp\Psr7\Utils::readLine`
`public static function readLine(StreamInterface $stream, int $maxLength = null): string`
Read a line from the stream up to the maximum allowed buffer length.
## `GuzzleHttp\Psr7\Utils::streamFor`
`public static function streamFor(resource|string|null|int|float|bool|StreamInterface|callable|\Iterator $resource = '', array $options = []): StreamInterface`
Create a new stream based on the input type.
Options is an associative array that can contain the following keys:
- metadata: Array of custom metadata.
- size: Size of the stream.
This method accepts the following `$resource` types:
- `Psr\Http\Message\StreamInterface`: Returns the value as-is.
- `string`: Creates a stream object that uses the given string as the contents.
- `resource`: Creates a stream object that wraps the given PHP stream resource.
- `Iterator`: If the provided value implements `Iterator`, then a read-only
stream object will be created that wraps the given iterable. Each time the
stream is read from, data from the iterator will fill a buffer and will be
continuously called until the buffer is equal to the requested read size.
Subsequent read calls will first read from the buffer and then call `next`
on the underlying iterator until it is exhausted.
- `object` with `__toString()`: If the object has the `__toString()` method,
the object will be cast to a string and then a stream will be returned that
uses the string value.
- `NULL`: When `null` is passed, an empty stream object is returned.
- `callable` When a callable is passed, a read-only stream object will be
created that invokes the given callable. The callable is invoked with the
number of suggested bytes to read. The callable can return any number of
bytes, but MUST return `false` when there is no more data to return. The
stream object that wraps the callable will invoke the callable until the
number of requested bytes are available. Any additional bytes will be
buffered and used in subsequent reads.
```php
$stream = GuzzleHttp\Psr7\Utils::streamFor('foo');
$stream = GuzzleHttp\Psr7\Utils::streamFor(fopen('/path/to/file', 'r'));
$generator = function ($bytes) {
for ($i = 0; $i < $bytes; $i++) {
yield ' ';
}
}
$stream = GuzzleHttp\Psr7\Utils::streamFor($generator(100));
```
## `GuzzleHttp\Psr7\Utils::tryFopen`
`public static function tryFopen(string $filename, string $mode): resource`
Safely opens a PHP stream resource using a filename.
When fopen fails, PHP normally raises a warning. This function adds an
error handler that checks for errors and throws an exception instead.
## `GuzzleHttp\Psr7\Utils::tryGetContents`
`public static function tryGetContents(resource $stream): string`
Safely gets the contents of a given stream.
When stream_get_contents fails, PHP normally raises a warning. This
function adds an error handler that checks for errors and throws an
exception instead.
## `GuzzleHttp\Psr7\Utils::uriFor`
`public static function uriFor(string|UriInterface $uri): UriInterface`
Returns a UriInterface for the given value.
This function accepts a string or UriInterface and returns a
UriInterface for the given value. If the value is already a
UriInterface, it is returned as-is.
## `GuzzleHttp\Psr7\MimeType::fromFilename`
`public static function fromFilename(string $filename): string|null`
Determines the mimetype of a file by looking at its extension.
## `GuzzleHttp\Psr7\MimeType::fromExtension`
`public static function fromExtension(string $extension): string|null`
Maps a file extensions to a mimetype.
## Upgrading from Function API
The static API was first introduced in 1.7.0, in order to mitigate problems with functions conflicting between global and local copies of the package. The function API was removed in 2.0.0. A migration table has been provided here for your convenience:
| Original Function | Replacement Method |
|----------------|----------------|
| `str` | `Message::toString` |
| `uri_for` | `Utils::uriFor` |
| `stream_for` | `Utils::streamFor` |
| `parse_header` | `Header::parse` |
| `normalize_header` | `Header::normalize` |
| `modify_request` | `Utils::modifyRequest` |
| `rewind_body` | `Message::rewindBody` |
| `try_fopen` | `Utils::tryFopen` |
| `copy_to_string` | `Utils::copyToString` |
| `copy_to_stream` | `Utils::copyToStream` |
| `hash` | `Utils::hash` |
| `readline` | `Utils::readLine` |
| `parse_request` | `Message::parseRequest` |
| `parse_response` | `Message::parseResponse` |
| `parse_query` | `Query::parse` |
| `build_query` | `Query::build` |
| `mimetype_from_filename` | `MimeType::fromFilename` |
| `mimetype_from_extension` | `MimeType::fromExtension` |
| `_parse_message` | `Message::parseMessage` |
| `_parse_request_uri` | `Message::parseRequestUri` |
| `get_message_body_summary` | `Message::bodySummary` |
| `_caseless_remove` | `Utils::caselessRemove` |
# Additional URI Methods
Aside from the standard `Psr\Http\Message\UriInterface` implementation in form of the `GuzzleHttp\Psr7\Uri` class,
this library also provides additional functionality when working with URIs as static methods.
## URI Types
An instance of `Psr\Http\Message\UriInterface` can either be an absolute URI or a relative reference.
An absolute URI has a scheme. A relative reference is used to express a URI relative to another URI,
the base URI. Relative references can be divided into several forms according to
[RFC 3986 Section 4.2](https://tools.ietf.org/html/rfc3986#section-4.2):
- network-path references, e.g. `//example.com/path`
- absolute-path references, e.g. `/path`
- relative-path references, e.g. `subpath`
The following methods can be used to identify the type of the URI.
### `GuzzleHttp\Psr7\Uri::isAbsolute`
`public static function isAbsolute(UriInterface $uri): bool`
Whether the URI is absolute, i.e. it has a scheme.
### `GuzzleHttp\Psr7\Uri::isNetworkPathReference`
`public static function isNetworkPathReference(UriInterface $uri): bool`
Whether the URI is a network-path reference. A relative reference that begins with two slash characters is
termed an network-path reference.
### `GuzzleHttp\Psr7\Uri::isAbsolutePathReference`
`public static function isAbsolutePathReference(UriInterface $uri): bool`
Whether the URI is a absolute-path reference. A relative reference that begins with a single slash character is
termed an absolute-path reference.
### `GuzzleHttp\Psr7\Uri::isRelativePathReference`
`public static function isRelativePathReference(UriInterface $uri): bool`
Whether the URI is a relative-path reference. A relative reference that does not begin with a slash character is
termed a relative-path reference.
### `GuzzleHttp\Psr7\Uri::isSameDocumentReference`
`public static function isSameDocumentReference(UriInterface $uri, UriInterface $base = null): bool`
Whether the URI is a same-document reference. A same-document reference refers to a URI that is, aside from its
fragment component, identical to the base URI. When no base URI is given, only an empty URI reference
(apart from its fragment) is considered a same-document reference.
## URI Components
Additional methods to work with URI components.
### `GuzzleHttp\Psr7\Uri::isDefaultPort`
`public static function isDefaultPort(UriInterface $uri): bool`
Whether the URI has the default port of the current scheme. `Psr\Http\Message\UriInterface::getPort` may return null
or the standard port. This method can be used independently of the implementation.
### `GuzzleHttp\Psr7\Uri::composeComponents`
`public static function composeComponents($scheme, $authority, $path, $query, $fragment): string`
Composes a URI reference string from its various components according to
[RFC 3986 Section 5.3](https://tools.ietf.org/html/rfc3986#section-5.3). Usually this method does not need to be called
manually but instead is used indirectly via `Psr\Http\Message\UriInterface::__toString`.
### `GuzzleHttp\Psr7\Uri::fromParts`
`public static function fromParts(array $parts): UriInterface`
Creates a URI from a hash of [`parse_url`](https://www.php.net/manual/en/function.parse-url.php) components.
### `GuzzleHttp\Psr7\Uri::withQueryValue`
`public static function withQueryValue(UriInterface $uri, $key, $value): UriInterface`
Creates a new URI with a specific query string value. Any existing query string values that exactly match the
provided key are removed and replaced with the given key value pair. A value of null will set the query string
key without a value, e.g. "key" instead of "key=value".
### `GuzzleHttp\Psr7\Uri::withQueryValues`
`public static function withQueryValues(UriInterface $uri, array $keyValueArray): UriInterface`
Creates a new URI with multiple query string values. It has the same behavior as `withQueryValue()` but for an
associative array of key => value.
### `GuzzleHttp\Psr7\Uri::withoutQueryValue`
`public static function withoutQueryValue(UriInterface $uri, $key): UriInterface`
Creates a new URI with a specific query string value removed. Any existing query string values that exactly match the
provided key are removed.
## Cross-Origin Detection
`GuzzleHttp\Psr7\UriComparator` provides methods to determine if a modified URL should be considered cross-origin.
### `GuzzleHttp\Psr7\UriComparator::isCrossOrigin`
`public static function isCrossOrigin(UriInterface $original, UriInterface $modified): bool`
Determines if a modified URL should be considered cross-origin with respect to an original URL.
## Reference Resolution
`GuzzleHttp\Psr7\UriResolver` provides methods to resolve a URI reference in the context of a base URI according
to [RFC 3986 Section 5](https://tools.ietf.org/html/rfc3986#section-5). This is for example also what web browsers
do when resolving a link in a website based on the current request URI.
### `GuzzleHttp\Psr7\UriResolver::resolve`
`public static function resolve(UriInterface $base, UriInterface $rel): UriInterface`
Converts the relative URI into a new URI that is resolved against the base URI.
### `GuzzleHttp\Psr7\UriResolver::removeDotSegments`
`public static function removeDotSegments(string $path): string`
Removes dot segments from a path and returns the new path according to
[RFC 3986 Section 5.2.4](https://tools.ietf.org/html/rfc3986#section-5.2.4).
### `GuzzleHttp\Psr7\UriResolver::relativize`
`public static function relativize(UriInterface $base, UriInterface $target): UriInterface`
Returns the target URI as a relative reference from the base URI. This method is the counterpart to resolve():
```php
(string) $target === (string) UriResolver::resolve($base, UriResolver::relativize($base, $target))
```
One use-case is to use the current request URI as base URI and then generate relative links in your documents
to reduce the document size or offer self-contained downloadable document archives.
```php
$base = new Uri('http://example.com/a/b/');
echo UriResolver::relativize($base, new Uri('http://example.com/a/b/c')); // prints 'c'.
echo UriResolver::relativize($base, new Uri('http://example.com/a/x/y')); // prints '../x/y'.
echo UriResolver::relativize($base, new Uri('http://example.com/a/b/?q')); // prints '?q'.
echo UriResolver::relativize($base, new Uri('http://example.org/a/b/')); // prints '//example.org/a/b/'.
```
## Normalization and Comparison
`GuzzleHttp\Psr7\UriNormalizer` provides methods to normalize and compare URIs according to
[RFC 3986 Section 6](https://tools.ietf.org/html/rfc3986#section-6).
### `GuzzleHttp\Psr7\UriNormalizer::normalize`
`public static function normalize(UriInterface $uri, $flags = self::PRESERVING_NORMALIZATIONS): UriInterface`
Returns a normalized URI. The scheme and host component are already normalized to lowercase per PSR-7 UriInterface.
This methods adds additional normalizations that can be configured with the `$flags` parameter which is a bitmask
of normalizations to apply. The following normalizations are available:
- `UriNormalizer::PRESERVING_NORMALIZATIONS`
Default normalizations which only include the ones that preserve semantics.
- `UriNormalizer::CAPITALIZE_PERCENT_ENCODING`
All letters within a percent-encoding triplet (e.g., "%3A") are case-insensitive, and should be capitalized.
Example: `http://example.org/a%c2%b1b` → `http://example.org/a%C2%B1b`
- `UriNormalizer::DECODE_UNRESERVED_CHARACTERS`
Decodes percent-encoded octets of unreserved characters. For consistency, percent-encoded octets in the ranges of
ALPHA (%41–%5A and %61–%7A), DIGIT (%30–%39), hyphen (%2D), period (%2E), underscore (%5F), or tilde (%7E) should
not be created by URI producers and, when found in a URI, should be decoded to their corresponding unreserved
characters by URI normalizers.
Example: `http://example.org/%7Eusern%61me/` → `http://example.org/~username/`
- `UriNormalizer::CONVERT_EMPTY_PATH`
Converts the empty path to "/" for http and https URIs.
Example: `http://example.org` → `http://example.org/`
- `UriNormalizer::REMOVE_DEFAULT_HOST`
Removes the default host of the given URI scheme from the URI. Only the "file" scheme defines the default host
"localhost". All of `file:/myfile`, `file:///myfile`, and `file://localhost/myfile` are equivalent according to
RFC 3986.
Example: `file://localhost/myfile` → `file:///myfile`
- `UriNormalizer::REMOVE_DEFAULT_PORT`
Removes the default port of the given URI scheme from the URI.
Example: `http://example.org:80/` → `http://example.org/`
- `UriNormalizer::REMOVE_DOT_SEGMENTS`
Removes unnecessary dot-segments. Dot-segments in relative-path references are not removed as it would
change the semantics of the URI reference.
Example: `http://example.org/../a/b/../c/./d.html` → `http://example.org/a/c/d.html`
- `UriNormalizer::REMOVE_DUPLICATE_SLASHES`
Paths which include two or more adjacent slashes are converted to one. Webservers usually ignore duplicate slashes
and treat those URIs equivalent. But in theory those URIs do not need to be equivalent. So this normalization
may change the semantics. Encoded slashes (%2F) are not removed.
Example: `http://example.org//foo///bar.html` → `http://example.org/foo/bar.html`
- `UriNormalizer::SORT_QUERY_PARAMETERS`
Sort query parameters with their values in alphabetical order. However, the order of parameters in a URI may be
significant (this is not defined by the standard). So this normalization is not safe and may change the semantics
of the URI.
Example: `?lang=en&article=fred` → `?article=fred&lang=en`
### `GuzzleHttp\Psr7\UriNormalizer::isEquivalent`
`public static function isEquivalent(UriInterface $uri1, UriInterface $uri2, $normalizations = self::PRESERVING_NORMALIZATIONS): bool`
Whether two URIs can be considered equivalent. Both URIs are normalized automatically before comparison with the given
`$normalizations` bitmask. The method also accepts relative URI references and returns true when they are equivalent.
This of course assumes they will be resolved against the same base URI. If this is not the case, determination of
equivalence or difference of relative references does not mean anything.
## Version Guidance
| Version | Status | PHP Version |
|---------|----------------|------------------|
| 1.x | Security fixes | >=5.4,<8.1 |
| 2.x | Latest | ^7.2.5 \|\| ^8.0 |
| Version | Status | PHP Version |
|---------|--------------|--------------|
| 3.2 | Latest | >=7.4,<8.7 |
| 2.13 | Maintenance | >=7.2.5,<8.7 |
| 1.9 | End of Life | >=5.4,<8.2 |
## Quick Start
```php
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Utils;
$request = new Request('GET', 'https://example.com/api');
$response = new Response(200, ['Content-Type' => 'text/plain'], 'OK');
$stream = Utils::streamFor('request or response body');
echo $request->getMethod();
echo $response->getStatusCode();
echo $stream;
```
PSR-7 messages and URIs are immutable. Methods such as `withHeader()` and
`withUri()` return a changed copy instead of modifying the original object.
Streams are mutable body handles; reading, writing, seeking, and closing a
stream can change its cursor, contents, or usability.
```php
$request = $request->withHeader('Accept', 'application/json');
```
## Documentation
- [PSR-7 Messages](docs/psr-7-messages.md)
- [Streams and Decorators](docs/streams-and-decorators.md)
- [URI Helpers](docs/uri-helpers.md)
- [PSR-17 Factories](docs/psr-17-factories.md)
- [Message Helpers](docs/message-helpers.md)
- [Diagnostic Values](docs/diagnostic-values.md)
- [Header and Query Helpers](docs/header-and-query-helpers.md)
- [Stream Helpers](docs/stream-helpers.md)
- [URI and MIME Helpers](docs/uri-and-mime-helpers.md)
- [Upgrade Guide](UPGRADING.md)
- [Changelog](CHANGELOG.md)
## Security
If you discover a security vulnerability within this package, please send an email to security@tidelift.com. All security vulnerabilities will be promptly addressed. Please do not disclose security-related issues publicly until a fix has been announced. Please see [Security Policy](https://github.com/guzzle/psr7/security/policy) for more information.
If you discover a security vulnerability within this package, please send an
email to security@tidelift.com. All security vulnerabilities will be promptly
addressed by the security maintainers listed in the
[Security Policy](https://github.com/guzzle/psr7/security/policy). Please do not
disclose security-related issues publicly until a fix has been announced.
## License
Guzzle is made available under the MIT License (MIT). Please see [License File](LICENSE) for more information.
Guzzle is made available under the MIT License (MIT). Please see
[License File](LICENSE) for more information.
## For Enterprise
Available as part of the Tidelift Subscription
The maintainers of Guzzle and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/packagist-guzzlehttp-psr7?utm_source=packagist-guzzlehttp-psr7&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)
The maintainers of Guzzle and thousands of other packages are working with
Tidelift to deliver commercial support and maintenance for the open source
dependencies you use to build your applications. Save time, reduce risk, and
improve code health, while paying the maintainers of the exact dependencies you
use.
[Learn more.](https://tidelift.com/subscription/pkg/packagist-guzzlehttp-psr7?utm_source=packagist-guzzlehttp-psr7&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)
File diff suppressed because it is too large. Load diff
+34 -18
View File
@@ -1,6 +1,7 @@
{
"name": "guzzlehttp/psr7",
"description": "PSR-7 message implementation that also provides common utility methods",
"license": "MIT",
"keywords": [
"request",
"response",
@@ -11,7 +12,6 @@
"url",
"psr-7"
],
"license": "MIT",
"authors": [
{
"name": "Graham Campbell",
@@ -50,23 +50,39 @@
}
],
"require": {
"php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0",
"ralouphie/getallheaders": "^3.0"
},
"provide": {
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
"php": "^7.4 || ^8.0",
"psr/http-factory": "^1.1",
"psr/http-message": "^2.0",
"symfony/polyfill-php80": "^1.25",
"symfony/polyfill-php82": "^1.27"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.1",
"http-interop/http-factory-tests": "^0.9",
"phpunit/phpunit": "^8.5.29 || ^9.5.23"
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "1.1.0",
"jshttp/mime-db": "1.54.0.1",
"php-http/psr7-integration-tests": "^1.5.1",
"phpunit/phpunit": "^9.6.34"
},
"provide": {
"psr/http-factory-implementation": "1.1",
"psr/http-message-implementation": "2.0"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
},
"repositories": [
{
"type": "package",
"package": {
"name": "jshttp/mime-db",
"version": "1.54.0.1",
"dist": {
"type": "zip",
"url": "https://codeload.github.com/jshttp/mime-db/zip/0a9fd0bfbc87a725ff638495839114e7807b7177"
}
}
}
],
"autoload": {
"psr-4": {
"GuzzleHttp\\Psr7\\": "src/"
@@ -77,17 +93,17 @@
"GuzzleHttp\\Tests\\Psr7\\": "tests/"
}
},
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"config": {
"allow-plugins": {
"bamarni/composer-bin-plugin": true
},
"preferred-install": "dist",
"sort-packages": true
},
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
}
}
@@ -0,0 +1,30 @@
# Diagnostic Values
## `GuzzleHttp\Psr7\DiagnosticValue::escape`
`public static function escape(string $value): string`
Escapes C0, DEL, and C1 controls as uppercase `\xNN` sequences.
ASCII bytes from 0x20 through 0x7E and valid UTF-8 characters outside those
control ranges remain unchanged. If the input is malformed UTF-8 or PCRE cannot
process it, every byte outside printable ASCII is escaped. Valid C1 characters
are rendered as `\xNN` using their Unicode code points. During bytewise
fallback, each original byte outside printable ASCII is rendered in the same
form. The result is diagnostic text, not a reversible encoding.
This does not encode values for HTML, JSON, shells, terminals, URLs, or protocol
fields.
Use this helper when including an untrusted value in diagnostic text:
```php
use GuzzleHttp\Psr7\DiagnosticValue;
$value = "bad\nvalue";
$message = sprintf('Invalid value: %s', DiagnosticValue::escape($value));
// Invalid value: bad\x0Avalue
```
Applications must still encode the completed diagnostic for its final output
context, such as HTML or JSON.
@@ -0,0 +1,107 @@
# Header and Query Helpers
This page covers helper methods for parsing structured header values, splitting
list headers, and parsing or building query strings. For basic message header
behavior, see [PSR-7 Messages](psr-7-messages.md).
## `GuzzleHttp\Psr7\Header::parse`
`public static function parse(string|array $header): array`
Parses semicolon-separated header parameters into associative arrays, one per
comma-separated header value. Parameters without a value are appended as values
under integer keys.
## `GuzzleHttp\Psr7\Header::splitList`
`public static function splitList(string|string[] $header): string[]`
Splits an HTTP header defined to contain a comma-separated list into each
individual value. Empty values are removed:
```php
$knownEtags = Header::splitList($request->getHeader('if-none-match'));
```
Example headers include `accept`, `cache-control`, and `if-none-match`.
This method must not be used to parse headers that are not defined as a list,
such as `user-agent` or `set-cookie`.
## `GuzzleHttp\Psr7\Query::parse`
`public static function parse(string $str, int|bool $urlEncoding = true): array`
Parse a query string into an associative array.
If multiple values are found for the same key, the value of that key-value pair
becomes an array. This function does not parse nested PHP style arrays into an
associative array. For example, `foo[a]=1&foo[b]=2` will be parsed into
`['foo[a]' => '1', 'foo[b]' => '2']`.
## `GuzzleHttp\Psr7\Query::build`
`public static function build(array $params, int|false $encoding = PHP_QUERY_RFC3986, bool $treatBoolsAsInts = true): string`
Build a query string from an array of key-value pairs.
This function can use the return value of `parse()` to build a query string.
This function does not modify the provided keys when an array is encountered,
unlike `http_build_query()`.
## `GuzzleHttp\Psr7\Utils::asciiToLower`
`public static function asciiToLower(string $string): string`
Converts ASCII uppercase letters in a string to lowercase.
Unlike `strtolower()`, which honors `LC_CTYPE` before PHP 8.2, the conversion is
locale-independent and leaves every non-ASCII byte unchanged, as HTTP protocol
elements require.
## `GuzzleHttp\Psr7\Utils::asciiToUpper`
`public static function asciiToUpper(string $string): string`
Converts ASCII lowercase letters in a string to uppercase.
Unlike `strtoupper()`, which honors `LC_CTYPE` before PHP 8.2, the conversion is
locale-independent and leaves every non-ASCII byte unchanged, as HTTP protocol
elements require.
## `GuzzleHttp\Psr7\Utils::asciiUcFirst`
`public static function asciiUcFirst(string $string): string`
Converts the first character of a string to uppercase when it is an ASCII
lowercase letter.
Unlike `ucfirst()`, which honors `LC_CTYPE` before PHP 8.2, the conversion is
locale-independent and leaves every non-ASCII byte unchanged, as HTTP protocol
elements require.
## `GuzzleHttp\Psr7\Utils::caselessContains`
`public static function caselessContains(string $haystack, string $needle): bool`
Checks whether the haystack contains the needle, comparing ASCII letters
case-insensitively and without locale sensitivity.
## `GuzzleHttp\Psr7\Utils::caselessEquals`
`public static function caselessEquals(string $left, string $right): bool`
Checks whether two strings are equal, comparing ASCII letters case-insensitively
and without locale sensitivity.
## `GuzzleHttp\Psr7\Utils::caselessRemove`
`public static function caselessRemove(array $keys, array $data): array`
Remove the items given by the keys from the data, case-insensitively.
## Related
- [PSR-7 Messages](psr-7-messages.md)
- [Message Helpers](message-helpers.md)
- [URI Helpers](uri-helpers.md)
@@ -0,0 +1,112 @@
# Message Helpers
This page covers static helper methods for converting, parsing, summarizing,
rewinding, and cloning PSR-7 messages. For conceptual request and response
behavior, start with [PSR-7 Messages](psr-7-messages.md).
## `GuzzleHttp\Psr7\Message::toString`
`public static function toString(MessageInterface $message): string`
Returns the string representation of an HTTP message.
```php
$request = new GuzzleHttp\Psr7\Request('GET', 'http://example.com');
echo GuzzleHttp\Psr7\Message::toString($request);
```
## `GuzzleHttp\Psr7\Message::bodySummary`
`public static function bodySummary(MessageInterface $message, ?int $truncateAt = null): string|null`
Get a short summary of the message body.
Will return `null` if the response is not printable.
Reads seekable bodies from the beginning and restores the original cursor
position before returning. Pass `null` for `$truncateAt` to use the default
summary length.
## `GuzzleHttp\Psr7\Message::rewindBody`
`public static function rewindBody(MessageInterface $message): void`
Attempts to rewind a message body and throws an exception on failure.
The body of the message will only be rewound if a call to `tell()` returns a
value other than `0`.
## `GuzzleHttp\Psr7\Message::parseMessage`
`public static function parseMessage(string $message): array`
Parses an HTTP message into an associative array.
The array contains the `start-line` key containing the start line of the
message, `headers` key containing an associative array of header array values,
and a `body` key containing the body of the message.
## `GuzzleHttp\Psr7\Message::parseRequestUri`
`public static function parseRequestUri(string $path, array $headers): string`
Constructs a URI for an HTTP request message.
The URI is composed from the start-line path and the `Host` header, using
`https` when the host's port is `443` and `http` otherwise. Without a `Host`
header, only the path is returned, with extra leading slashes collapsed so an
origin-form target cannot be parsed as a network-path reference with its own
authority. An `InvalidArgumentException` is thrown when the `Host` header is
invalid.
## `GuzzleHttp\Psr7\Message::parseRequest`
`public static function parseRequest(string $message): RequestInterface`
Parses a request message string into a request object.
The request-target must be in origin form, absolute form (without a userinfo
component), authority form (`CONNECT`), or asterisk form (`OPTIONS`), and any
`Host` header must be a single valid value; otherwise an
`InvalidArgumentException` is thrown. Non-origin-form targets are preserved on
the returned request via `withRequestTarget()`.
## `GuzzleHttp\Psr7\Message::parseResponse`
`public static function parseResponse(string $message): ResponseInterface`
Parses a response message string into a response object.
## `GuzzleHttp\Psr7\Utils::modifyRequest`
`public static function modifyRequest(RequestInterface $request, array $changes): RequestInterface`
Clone and modify a request with the given changes.
This method is useful for reducing the number of clones needed to mutate a
message.
The changes can be one of:
- method: (string) Changes the HTTP method.
- set_headers: (array) Sets the given headers. Values must be strings or
non-empty arrays of strings.
- remove_headers: (array) Remove the given headers. Values may be strings or
integers.
- body: (mixed) Sets the given body. Present non-null values are converted with
`GuzzleHttp\Psr7\Utils::streamFor()`, including resources, streams, iterators,
callable arrays, closures, invokable objects, and stringable objects. String
inputs remain literal bodies.
- uri: (UriInterface) Set the URI. When the URI contains a host, the Host header
is updated from it, and combining this with an explicit Host entry in
set_headers throws an InvalidArgumentException. Apply an intentional Host
override separately with withHeader() afterwards.
- query: (string) Set the query string value of the URI.
- version: (string) Set the protocol version.
## Related
- [PSR-7 Messages](psr-7-messages.md)
- [Header and Query Helpers](header-and-query-helpers.md)
- [Stream Helpers](stream-helpers.md)
- [URI and MIME Helpers](uri-and-mime-helpers.md)
@@ -0,0 +1,33 @@
# PSR-17 Factories
This page explains `GuzzleHttp\Psr7\HttpFactory`, the PSR-17 factory implementation provided by this package. Use it when code expects PSR-17 factory interfaces and you want factories that create Guzzle PSR-7 messages, streams, uploaded files, and URIs.
## `GuzzleHttp\Psr7\HttpFactory`
`GuzzleHttp\Psr7\HttpFactory` implements all PSR-17 factory interfaces: `RequestFactoryInterface`, `ResponseFactoryInterface`, `ServerRequestFactoryInterface`, `StreamFactoryInterface`, `UploadedFileFactoryInterface`, and `UriFactoryInterface`.
```php
use GuzzleHttp\Psr7\HttpFactory;
$factory = new HttpFactory();
$request = $factory->createRequest('GET', 'https://example.com');
$response = $factory->createResponse(200);
$serverRequest = $factory->createServerRequest('POST', '/submit', ['REMOTE_ADDR' => '192.0.2.1']);
$stream = $factory->createStream('body');
$uri = $factory->createUri('https://example.com/path');
```
It also creates streams from files and resources, and uploaded files from streams.
```php
$stream = $factory->createStreamFromFile('/path/to/file.txt', 'r');
$upload = $factory->createUploadedFile($stream, $stream->getSize(), UPLOAD_ERR_OK, 'file.txt', 'text/plain');
```
## Related
- [PSR-7 Messages](psr-7-messages.md)
- [Streams and Decorators](streams-and-decorators.md)
- [URI Helpers](uri-helpers.md)
- [Message Helpers](message-helpers.md)
@@ -0,0 +1,402 @@
# PSR-7 Messages
This page covers the PSR-7 message objects provided by this package: requests, responses, server requests, uploaded files, and the message-specific header, URI, and body APIs. Use these objects when you need HTTP messages that can move between Guzzle, PSR-18 clients, PSR-15 middleware, and other PSR-7 compatible libraries.
HTTP requests and responses are both messages. A message has a start line, headers, and an optional body stream. Message and URI objects are immutable; methods named `with*()` return changed copies. Body streams are mutable handles, so reads and writes can change their cursor or contents. For body details, see [Streams and Decorators](streams-and-decorators.md). For URI helpers, see [URI Helpers](uri-helpers.md).
## Creating Requests
You can create a request with `GuzzleHttp\Psr7\Request`.
```php
use GuzzleHttp\Psr7\Request;
$request = new Request('GET', 'https://example.com/users/123');
// You can provide optional headers and a body.
$headers = ['Accept' => 'application/json'];
$body = 'request body';
$request = new Request('PUT', 'https://example.com/users/123', $headers, $body);
```
## Creating Responses
You can create a response with `GuzzleHttp\Psr7\Response`.
```php
use GuzzleHttp\Psr7\Response;
// The constructor requires no arguments.
$response = new Response();
echo $response->getStatusCode();
// 200
echo $response->getProtocolVersion();
// 1.1
// You can provide a status, headers, body, and protocol version.
$response = new Response(200, ['Content-Type' => 'application/json'], '{"ok":true}', '1.1');
```
## Creating Server Requests
Server requests represent incoming HTTP requests on the server side. They include the normal request method, URI, headers, and body, plus server parameters, cookies, query parameters, parsed body data, attributes, and uploaded files.
```php
use GuzzleHttp\Psr7\ServerRequest;
$request = new ServerRequest('POST', 'https://example.com/form', [], 'name=Guzzle', '1.1', [
'REMOTE_ADDR' => '192.0.2.1',
]);
$request = $request
->withCookieParams(['session' => 'abc'])
->withQueryParams(['page' => '1'])
->withParsedBody(['name' => 'Guzzle'])
->withAttribute('route', 'profile');
echo $request->getServerParams()['REMOTE_ADDR'];
echo $request->getCookieParams()['session'];
echo $request->getQueryParams()['page'];
echo $request->getParsedBody()['name'];
echo $request->getAttribute('route');
```
Use `ServerRequest::fromGlobals()` to create a server request from PHP superglobals. It reads `$_SERVER`, `$_GET`, `$_POST`, `$_COOKIE`, and `$_FILES`, and attempts to include request headers when available.
```php
use GuzzleHttp\Psr7\ServerRequest;
$request = ServerRequest::fromGlobals();
```
Use `ServerRequest::getUriFromGlobals()` when you only need the URI derived from `$_SERVER`.
```php
use GuzzleHttp\Psr7\ServerRequest;
$uri = ServerRequest::getUriFromGlobals();
```
For URI construction and normalization helpers, see [URI Helpers](uri-helpers.md).
## Requests
```php
use GuzzleHttp\Psr7\Request;
$request = new Request('GET', 'https://example.com/users/123', [
'Accept' => 'application/json',
]);
echo $request->getMethod();
echo $request->getUri();
```
PSR-7 messages are immutable. Methods such as `withHeader()` and `withUri()` return a modified copy.
```php
$jsonRequest = $request->withHeader('Accept', 'application/json');
```
## Responses
```php
use GuzzleHttp\Psr7\Response;
$response = new Response(200, ['Content-Type' => 'application/json'], '{"ok":true}');
echo $response->getStatusCode();
echo $response->getHeaderLine('Content-Type');
echo $response->getBody();
```
## URIs
```php
use GuzzleHttp\Psr7\Uri;
$uri = new Uri('https://example.com/users?active=1');
echo $uri->getHost();
echo $uri->getQuery();
```
For URI-specific helper methods, see [URI Helpers](uri-helpers.md).
## Headers
Both request and response messages contain HTTP headers.
### Accessing Headers
You can check if a request or response has a specific header using `hasHeader()`.
```php
use GuzzleHttp\Psr7\Request;
$request = new Request('GET', '/', ['X-Foo' => 'bar']);
if ($request->hasHeader('X-Foo')) {
echo 'It is there';
}
```
Retrieve all header values as an array of strings with `getHeader()`.
```php
$request->getHeader('X-Foo');
// ['bar']
// Missing headers return an empty array.
$request->getHeader('X-Bar');
// []
```
Iterate over the headers of a message with `getHeaders()`.
```php
foreach ($request->getHeaders() as $name => $values) {
echo $name . ': ' . implode(', ', $values) . "\r\n";
}
```
### Complex Headers
Some headers contain additional key-value pair information. For example, `Link` headers contain a link and additional parameters:
```http
<https://example.com/front.jpeg>; rel="front"; type="image/jpeg"
```
Use `GuzzleHttp\Psr7\Header::parse()` to parse these headers.
```php
use GuzzleHttp\Psr7\Header;
use GuzzleHttp\Psr7\Request;
$request = new Request('GET', '/', [
'Link' => '<https://example.com/front.jpeg>; rel="front"; type="image/jpeg"',
]);
$parsed = Header::parse($request->getHeader('Link'));
var_export($parsed);
```
This outputs:
```php
array (
0 =>
array (
0 => '<https://example.com/front.jpeg>',
'rel' => 'front',
'type' => 'image/jpeg',
),
)
```
The result contains key-value pairs. Header values that have no key are indexed numerically, while header parts that form a key-value pair are added with their parameter name.
## Body
Request and response bodies are `Psr\Http\Message\StreamInterface` instances. Streams are used for both uploading data and downloading data.
```php
use GuzzleHttp\Psr7\Response;
$response = new Response(200, [], 'response body');
echo $response->getBody();
// response body
```
The body can be cast to a string, or you can read bytes from the stream as needed.
```php
$body = $response->getBody();
echo $body->read(4);
$body->seek(0);
echo $body->getContents();
```
For more stream creation and decorator examples, see [Streams and Decorators](streams-and-decorators.md).
## Uploaded Files
Uploaded files are represented by `Psr\Http\Message\UploadedFileInterface` instances. This package provides `GuzzleHttp\Psr7\UploadedFile`, which can wrap a local file path, PHP stream resource, or PSR-7 stream.
```php
use GuzzleHttp\Psr7\UploadedFile;
use GuzzleHttp\Psr7\Utils;
$stream = Utils::streamFor('file contents');
$upload = new UploadedFile($stream, $stream->getSize(), UPLOAD_ERR_OK, 'example.txt', 'text/plain');
echo $upload->getClientFilename();
echo $upload->getClientMediaType();
echo $upload->getSize();
```
Call `getStream()` to read the uploaded content, or `moveTo()` to move or copy it to a target path. After `moveTo()` succeeds, `isMoved()` returns `true`, and calls that need the active upload stream will throw.
```php
$body = $upload->getStream();
echo $body->getContents();
$upload->moveTo('/path/to/target.txt');
var_export($upload->isMoved());
// true
```
If the upload error code is not `UPLOAD_ERR_OK`, the object still exposes `getError()`, `getSize()`, `getClientFilename()`, and `getClientMediaType()`, but `getStream()` and `moveTo()` throw because no successful upload content is available.
`ServerRequest::normalizeFiles()` converts a `$_FILES`-style array into a tree of uploaded file instances. It accepts simple file specs, nested PHP `$_FILES` shapes, existing `UploadedFileInterface` instances, and nested arrays of uploaded files.
```php
use GuzzleHttp\Psr7\ServerRequest;
$files = ServerRequest::normalizeFiles([
'avatar' => [
'tmp_name' => '/tmp/php123',
'size' => 1024,
'error' => UPLOAD_ERR_OK,
'name' => 'avatar.png',
'type' => 'image/png',
],
'photos' => [
'tmp_name' => [
'first' => '/tmp/php456',
],
'size' => [
'first' => 2048,
],
'error' => [
'first' => UPLOAD_ERR_OK,
],
'name' => [
'first' => 'photo.jpg',
],
'type' => [
'first' => 'image/jpeg',
],
],
]);
$request = (new ServerRequest('POST', '/upload'))->withUploadedFiles($files);
```
## HTTP Method Casing
HTTP method names are case-sensitive in PSR-7. Requests created explicitly with
`Request`, `ServerRequest`, `withMethod()`, `Message::parseRequest()`, or the
PSR-17 factories preserve the method string as provided. `ServerRequest::fromGlobals()`
normalizes `$_SERVER['REQUEST_METHOD']` to uppercase for compatibility when
hydrating requests from PHP server globals.
## Request Methods
When creating a request, provide the HTTP method you want to perform. You can specify any method, including custom methods that are not part of RFC 9110.
```php
use GuzzleHttp\Psr7\Request;
$request = new Request('MOVE', 'https://example.com/resource');
echo $request->getMethod();
// MOVE
```
## Request URI
The request URI is represented by a `Psr\Http\Message\UriInterface` object. This package provides an implementation through `GuzzleHttp\Psr7\Uri`.
When creating a request, you can provide the URI as a string or as a `UriInterface` instance.
```php
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Uri;
$request = new Request('GET', new Uri('https://example.com/users?id=123'));
```
### Scheme
The [scheme](https://datatracker.ietf.org/doc/html/rfc3986#section-3.1) specifies the protocol. For HTTP requests, this is usually `http` or `https`.
```php
$request = new Request('GET', 'https://example.com');
echo $request->getUri()->getScheme();
// https
```
### Host
The host is accessible from the URI and is also represented by the `Host` header.
```php
$request = new Request('GET', 'https://example.com');
echo $request->getUri()->getHost();
// example.com
echo $request->getHeaderLine('Host');
// example.com
```
### Port
No port is necessary for the default `http` and `https` ports.
```php
$request = new Request('GET', 'https://example.com:8443');
echo $request->getUri()->getPort();
// 8443
```
### Path
The request path is accessible through the URI object.
```php
$request = new Request('GET', 'https://example.com/users/123');
echo $request->getUri()->getPath();
// /users/123
```
Characters that are not allowed in a URI path are percent-encoded according to [RFC 3986 section 3.3](https://datatracker.ietf.org/doc/html/rfc3986#section-3.3).
### Query String
The query string is accessible through the URI object.
```php
$request = new Request('GET', 'https://example.com/?foo=bar');
echo $request->getUri()->getQuery();
// foo=bar
```
Characters that are not allowed in a URI query are percent-encoded according to [RFC 3986 section 3.4](https://datatracker.ietf.org/doc/html/rfc3986#section-3.4).
## Response Status
Responses expose the status code, reason phrase, and protocol version.
```php
use GuzzleHttp\Psr7\Response;
$response = new Response(200, [], 'OK');
echo $response->getStatusCode();
// 200
echo $response->getReasonPhrase();
// OK
echo $response->getProtocolVersion();
// 1.1
```
## Related
- [Streams and Decorators](streams-and-decorators.md)
- [URI Helpers](uri-helpers.md)
- [Message Helpers](message-helpers.md)
- [Header and Query Helpers](header-and-query-helpers.md)
- [PSR-17 Factories](psr-17-factories.md)
@@ -0,0 +1,146 @@
# Stream Helpers
This page covers `GuzzleHttp\Psr7\Utils` helper methods for creating, copying,
hashing, reading, and safely opening PSR-7 streams. For stream implementations
and decorators, see [Streams and Decorators](streams-and-decorators.md).
## `GuzzleHttp\Psr7\Utils::copyToStream`
`public static function copyToStream(StreamInterface $source, StreamInterface $dest, int $maxLen = -1): int`
Copy the contents of a stream into another stream until the given number of
bytes have been read, returning the number of bytes copied as an `int`. On
32-bit PHP, an unbounded copy larger than `PHP_INT_MAX` bytes cannot be
represented by that return type. 64-bit PHP is not affected.
The destination must accept writes that make positive progress. Streams that
return 0 as a backpressure or drop signal (a `BufferStream` at its high water
mark, or a full `DroppingStream`) will cause this method to throw. For full
copies, use a normal writable stream such as a file or `php://temp` stream.
Throws `GuzzleHttp\Psr7\Exception\TimeoutException` when PHP-style timeout
metadata can be detected after a source read or destination write cannot make
progress.
When a source read times out, the bytes copied before it remain in the
destination.
## `GuzzleHttp\Psr7\Utils::copyToString`
`public static function copyToString(StreamInterface $stream, int $maxLen = -1): string`
Copy the contents of a stream into a string until the given number of bytes have
been read.
Throws `GuzzleHttp\Psr7\Exception\TimeoutException` when PHP-style timeout
metadata can be detected after a stream read cannot make progress.
The bytes read before a timeout are discarded, so calling this method again does
not return them.
## `GuzzleHttp\Psr7\Utils::hash`
`public static function hash(StreamInterface $stream, string $algo, bool $rawOutput = false): string`
Calculate a hash of a stream.
This method reads the entire stream to calculate a rolling hash, based on PHP's
`hash_init` functions.
Throws `GuzzleHttp\Psr7\Exception\TimeoutException` when PHP-style timeout
metadata can be detected after a stream read cannot make progress.
## `GuzzleHttp\Psr7\Utils::readLine`
`public static function readLine(StreamInterface $stream, ?int $maxLength = null): string`
Read a line from the stream up to the maximum allowed buffer length.
Throws `GuzzleHttp\Psr7\Exception\TimeoutException` when PHP-style timeout
metadata can be detected after a stream read cannot make progress.
The bytes read before a timeout are discarded, so calling this method again does
not return them.
## `GuzzleHttp\Psr7\Utils::streamFor`
`public static function streamFor(resource|string|null|StreamInterface|callable|\Iterator|\Stringable $resource = '', array $options = []): StreamInterface`
Create a new stream based on the input type.
Options are provided as an associative array that can contain the following
keys:
- metadata: Array of custom metadata.
- size: Size of the stream.
This method accepts the following `$resource` types:
- `Psr\Http\Message\StreamInterface`: Returns the value as-is.
- `string`: Creates a stream object that uses the given string as the contents.
- `resource`: Creates a stream object that wraps the given PHP stream resource.
- `Iterator`: If the provided value implements `Iterator`, then a read-only
stream object will be created that wraps the given iterable. Each time the
stream is read from, data from the iterator will fill a buffer and will be
continuously called until the buffer is equal to the requested read size.
Yielded strings, integers, finite floats, booleans, `null`, and stringable
objects are converted to string chunks; non-finite floats and other values
throw `UnexpectedValueException` when the stream is read. Values that
stringify to an empty string are skipped while the iterator advances.
Subsequent read calls will first read from the buffer and then call `next` on
the underlying iterator until it is exhausted.
- `object` with `__toString()`: If the object has the `__toString()` method, the
object will be cast to a string and then a stream will be returned that uses
the string value.
- `NULL`: When `null` is passed, an empty stream object is returned.
- `callable`: When a callable array, closure, or invokable object is passed and
no earlier resource or object rule applies, a read-only stream object will be
created that invokes the given callable. The callable is invoked with the
suggested number of bytes to read. The callable can return fewer or more bytes
than requested, but MUST return a non-empty string to provide data and MUST
return `false` or `null` when there is no more data to return. Any additional
bytes will be buffered and used in subsequent reads. String inputs are always
treated as string bodies, even when they name callable functions.
```php
$stream = GuzzleHttp\Psr7\Utils::streamFor('foo');
$stream = GuzzleHttp\Psr7\Utils::streamFor(fopen('/path/to/file', 'r'));
$generator = function ($bytes) {
for ($i = 0; $i < $bytes; $i++) {
yield ' ';
}
};
$stream = GuzzleHttp\Psr7\Utils::streamFor($generator(100));
```
## `GuzzleHttp\Psr7\Utils::tryFopen`
`public static function tryFopen(string $filename, string $mode): resource`
Safely opens a PHP stream resource using a filename.
When `fopen()` fails, PHP normally raises a warning. This function adds an error
handler that checks for errors and throws an exception instead.
## `GuzzleHttp\Psr7\Utils::tryGetContents`
`public static function tryGetContents(resource $stream): string`
Safely gets the contents of a given stream.
When `stream_get_contents()` fails, PHP normally raises a warning. This function
adds an error handler that checks for errors and throws an exception instead.
Throws `GuzzleHttp\Psr7\Exception\TimeoutException` when PHP-style timeout
metadata can be detected after a stream read cannot make progress.
The bytes read before a timeout are discarded, so calling this method again does
not return them.
## Related
- [Streams and Decorators](streams-and-decorators.md)
- [PSR-17 Factories](psr-17-factories.md)
- [Message Helpers](message-helpers.md)
@@ -0,0 +1,397 @@
# Streams and Decorators
PSR-7 request and response bodies are streams. This page covers stream creation, cursor and I/O behavior, built-in stream decorators, and wrapping PSR-7 streams as PHP resources.
Streams allow HTTP messages to represent small strings, large files, generated data, remote resources, and other body sources through a common interface.
The PSR-7 `Psr\Http\Message\StreamInterface` exposes methods that let consumers read, write, seek, and inspect body data without requiring the entire body to be loaded into memory.
Streams expose their capabilities using `isReadable()`, `isWritable()`, and `isSeekable()`. These methods help collaborators determine whether a stream supports the operations they need.
## Creating Streams
Use `GuzzleHttp\Psr7\Utils::streamFor()` to create streams from common PHP values. It accepts strings, resources returned from `fopen()`, objects that implement `__toString()`, iterators, callable arrays, closures, invokable objects, and existing `Psr\Http\Message\StreamInterface` instances.
Strings and `null` are stored in `php://temp` streams. PHP keeps `php://temp` data in memory until the stream exceeds 2 MB, then spills to a temporary file on disk. Non-string scalars such as integers, floats, and booleans are rejected; cast them to strings first.
Callable sources receive a suggested read length, may return fewer or more bytes, and end the stream by returning `false` or `null`. Strings remain literal body contents, even when they name a callable.
```php
use GuzzleHttp\Psr7\Utils;
$stream = Utils::streamFor('string data');
echo $stream;
// string data
echo $stream->read(3);
// str
echo $stream->getContents();
// ing data
var_export($stream->eof());
// true
var_export($stream->tell());
// 11
```
You can create streams from iterators. The iterator can yield any number of bytes per iteration. Any excess bytes returned by the iterator that were not requested by a stream consumer will be buffered until a subsequent read.
```php
use GuzzleHttp\Psr7\Utils;
$generator = function ($bytes) {
for ($i = 0; $i < $bytes; $i++) {
yield '.';
}
};
$stream = Utils::streamFor($generator(1024));
echo $stream->read(3);
// ...
```
## Metadata
Streams expose stream metadata through `getMetadata()`. This method provides the data returned by PHP's [stream_get_meta_data()](https://www.php.net/manual/en/function.stream-get-meta-data.php), and can optionally expose custom metadata.
```php
use GuzzleHttp\Psr7\Utils;
$resource = Utils::tryFopen('/path/to/file', 'r');
$stream = Utils::streamFor($resource);
echo $stream->getMetadata('uri');
// /path/to/file
var_export($stream->isReadable());
// true
var_export($stream->isWritable());
// false
var_export($stream->isSeekable());
// true
```
## Timeouts
Reads throw `GuzzleHttp\Psr7\Exception\TimeoutException` when PHP-style timeout
metadata can be detected after a read cannot make progress, such as on a socket
with a read timeout.
`getContents()`, `Utils::copyToString()`, and `Utils::readLine()` discard the
bytes they read before a timeout, so calling them again does not return those
bytes.
## AppendStream
`GuzzleHttp\Psr7\AppendStream`
Reads from multiple streams, one after the other.
```php
use GuzzleHttp\Psr7;
$a = Psr7\Utils::streamFor('abc, ');
$b = Psr7\Utils::streamFor('123.');
$composed = new Psr7\AppendStream([$a, $b]);
$composed->addStream(Psr7\Utils::streamFor(' Above all listen to me'));
echo $composed; // abc, 123. Above all listen to me.
```
## BufferStream
`GuzzleHttp\Psr7\BufferStream`
Provides a buffer stream that can be written to fill a buffer, then read
from it to remove bytes from the buffer.
This stream returns a "hwm" metadata value that tells upstream consumers
what the configured high water mark of the stream is, or the maximum
preferred size of the buffer.
```php
use GuzzleHttp\Psr7;
// When the buffer reaches or exceeds 1024 bytes, it will begin returning 0 to
// writes. This is an indication that writers should slow down.
$buffer = new Psr7\BufferStream(1024);
```
## CachingStream
The CachingStream is used to allow seeking over previously read bytes on
non-seekable streams. This can be useful when transferring a non-seekable
entity body fails due to needing to rewind the stream (for example, resulting
from a redirect). Data that is read from the remote stream will be buffered in
a PHP temp stream so that previously read bytes are cached first in memory,
then on disk.
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor(fopen('http://www.google.com', 'r'));
$stream = new Psr7\CachingStream($original);
$stream->read(1024);
echo $stream->tell();
// 1024
$stream->seek(0);
echo $stream->tell();
// 0
```
By default the bytes are cached in a `php://temp` stream. You can supply your own
cache target as the second constructor argument, but it is used as a random-access
byte buffer to replay the remote stream, so it must be readable, writable, and
seekable, report an accurate position and size, and store writes losslessly. Lossy
or non-seekable streams such as `BufferStream` and `DroppingStream` are not valid
targets.
## DroppingStream
`GuzzleHttp\Psr7\DroppingStream`
Stream decorator that begins dropping data once the size of the underlying
stream becomes too full.
```php
use GuzzleHttp\Psr7;
// Create an empty stream
$stream = Psr7\Utils::streamFor();
// Start dropping data when the stream has more than 10 bytes
$dropping = new Psr7\DroppingStream($stream, 10);
$dropping->write('01234567890123456789');
echo $stream; // 0123456789
```
## FnStream
`GuzzleHttp\Psr7\FnStream`
Compose stream implementations based on a hash of callables.
Allows for easy testing and extension of a provided stream without needing
to create a concrete class for a simple extension point.
```php
use GuzzleHttp\Psr7;
$stream = Psr7\Utils::streamFor('hi');
$fnStream = Psr7\FnStream::decorate($stream, [
'rewind' => function () use ($stream) {
echo 'About to rewind - ';
$stream->rewind();
echo 'rewound!';
}
]);
$fnStream->rewind();
// Outputs: About to rewind - rewound!
```
## InflateStream
`GuzzleHttp\Psr7\InflateStream`
Uses PHP's zlib.inflate filter to inflate zlib (HTTP deflate, RFC1950) or gzipped (RFC1952) content.
This stream decorator converts the provided stream to a PHP stream resource,
appends the zlib.inflate filter, and wraps the filtered resource as a stream.
Closing an `InflateStream` also closes the compressed source stream it decorates; `detach()` leaves the source stream open.
## LazyOpenStream
`GuzzleHttp\Psr7\LazyOpenStream`
Lazily reads from or writes to a file that is opened only after an I/O operation
takes place on the stream.
```php
use GuzzleHttp\Psr7;
$stream = new Psr7\LazyOpenStream('/path/to/file', 'r');
// The file has not yet been opened...
echo $stream->read(10);
// The file is opened and read from only when needed.
```
## LimitStream
`GuzzleHttp\Psr7\LimitStream`
LimitStream can be used to read a subset or slice of an existing stream object.
This can be useful for breaking a large file into smaller pieces to be sent in
chunks (e.g. Amazon S3's multipart upload API).
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor(fopen('/tmp/test.txt', 'r+'));
echo $original->getSize();
// >>> 1048576
// Limit the size of the body to 1024 bytes and start reading from byte 2048
$stream = new Psr7\LimitStream($original, 1024, 2048);
echo $stream->getSize();
// >>> 1024
echo $stream->tell();
// >>> 0
```
## MultipartStream
`GuzzleHttp\Psr7\MultipartStream`
A stream that returns bytes for a streaming multipart or multipart/form-data
body when read.
Each multipart element must contain a `name` and `contents` key. `contents` may
be any non-array value accepted by `GuzzleHttp\Psr7\Utils::streamFor()`,
including closures and invokable objects. Array contents are recursively
expanded into nested form fields.
## NoSeekStream
`GuzzleHttp\Psr7\NoSeekStream`
NoSeekStream wraps a stream and does not allow seeking.
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor('foo');
$noSeek = new Psr7\NoSeekStream($original);
echo $noSeek->read(3);
// foo
var_export($noSeek->isSeekable());
// false
try {
$noSeek->seek(0);
} catch (\RuntimeException $e) {
echo $e->getMessage();
// Cannot seek a NoSeekStream
}
```
## PumpStream
`GuzzleHttp\Psr7\PumpStream`
Provides a read-only stream that pumps data from a PHP callable.
When invoking the provided callable, the PumpStream will pass the suggested
number of bytes to read to the callable. The callable can choose to ignore
this value and return fewer or more bytes than requested. Any extra data
returned by the provided callable is buffered internally until drained using
the `read()` method of the PumpStream. The provided callable MUST return a
non-empty string to provide data, and MUST return false or null when there is
no more data to read. Returning an empty string causes a RuntimeException
because it cannot satisfy a positive-length read.
Userland callables that declare no parameters are tolerated by PHP, but
length-aware callables remain the recommended formal shape.
## Implementing Stream Decorators
Creating a stream decorator is very easy thanks to the
`GuzzleHttp\Psr7\StreamDecoratorTrait`. This trait provides methods that
implement `Psr\Http\Message\StreamInterface` by proxying to an underlying
stream. Just `use` the `StreamDecoratorTrait` and implement your custom
methods.
For example, let's say we wanted to call a specific function each time the last
byte is read from a stream. This could be implemented by overriding the
`read()` method.
```php
use Psr\Http\Message\StreamInterface;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
class EofCallbackStream implements StreamInterface
{
use StreamDecoratorTrait;
private $callback;
private $stream;
public function __construct(StreamInterface $stream, callable $cb)
{
$this->stream = $stream;
$this->callback = $cb;
}
public function read(int $length): string
{
$result = $this->stream->read($length);
// Invoke the callback when EOF is hit.
if ($this->eof()) {
($this->callback)();
}
return $result;
}
}
```
This decorator could be added to any existing stream and used like so:
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor('foo');
$eofStream = new EofCallbackStream($original, function () {
echo 'EOF!';
});
$eofStream->read(2);
$eofStream->read(1);
// echoes "EOF!"
$eofStream->seek(0);
$eofStream->read(3);
// echoes "EOF!"
```
## PHP StreamWrapper
You can use the `GuzzleHttp\Psr7\StreamWrapper` class if you need to use a
PSR-7 stream as a PHP stream resource.
Use the `GuzzleHttp\Psr7\StreamWrapper::getResource()` method to create a PHP
stream from a PSR-7 stream.
```php
use GuzzleHttp\Psr7\StreamWrapper;
$stream = GuzzleHttp\Psr7\Utils::streamFor('hello!');
$resource = StreamWrapper::getResource($stream);
echo fread($resource, 6); // outputs hello!
```
## Related
- [PSR-7 Messages](psr-7-messages.md)
- [Stream Helpers](stream-helpers.md)
- [PSR-17 Factories](psr-17-factories.md)
- [URI Helpers](uri-helpers.md)
@@ -0,0 +1,83 @@
# URI and MIME Helpers
This page covers small helper methods for redacting URI user info, converting
values into URI objects, and resolving MIME types from filenames or extensions.
For URI resolution, normalization, and comparison helpers, see
[URI Helpers](uri-helpers.md).
## `GuzzleHttp\Psr7\Utils::redactUserInfo`
`public static function redactUserInfo(UriInterface $uri): UriInterface`
Redact the user info part of a URI.
Returns the URI with the whole userinfo component replaced by `***` when one
is present, so neither the username nor the password survives into logs and
diagnostics. A URI without userinfo is returned unchanged.
## `GuzzleHttp\Psr7\Utils::redactUserInfoInString`
`public static function redactUserInfoInString(string $subject, string $uri): string`
Redacts the userinfo of a raw URI string wherever it appears in a subject
string.
The needle is taken verbatim from the raw URI rather than from parsed
components, so credentials that URI normalization would rewrite, such as raw
control bytes or unencoded reserved characters, are still found in text that
embeds the URI exactly as given, for example transport error messages. A URI
without `://` is treated as authority-form: a host and port with optional
userinfo.
A URI that does not parse has no trustworthy authority boundary, so everything
between any scheme and its last `@` is redacted as a safe-side fallback.
## `GuzzleHttp\Psr7\Utils::redactUriForMessage`
`public static function redactUriForMessage(UriInterface $uri): string`
Formats a URI for automatic diagnostics.
The whole userinfo component is replaced by "***", and the query and
fragment are removed. The scheme, host, port, and path are preserved.
The returned string is diagnostic-escaped and the formatter never
throws.
## `GuzzleHttp\Psr7\Utils::redactUriStringForMessage`
`public static function redactUriStringForMessage(string $uri): string`
Formats a raw URI for automatic diagnostics, including malformed input.
The whole userinfo component is replaced by "***", and the query and
fragment are removed. The scheme, host, port, and path are preserved
where their boundaries can be determined safely. The returned string is
diagnostic-escaped and the formatter never throws.
## `GuzzleHttp\Psr7\Utils::uriFor`
`public static function uriFor(string|UriInterface $uri): UriInterface`
Returns a `UriInterface` for the given value.
This function accepts a string or `UriInterface` and returns a `UriInterface`
for the given value. If the value is already a `UriInterface`, it is returned
as-is.
## `GuzzleHttp\Psr7\MimeType::fromFilename`
`public static function fromFilename(string $filename): string|null`
Determines the MIME type of a file by looking at its extension.
## `GuzzleHttp\Psr7\MimeType::fromExtension`
`public static function fromExtension(string $extension): string|null`
Maps a file extension to a MIME type.
## Related
- [URI Helpers](uri-helpers.md)
- [Header and Query Helpers](header-and-query-helpers.md)
- [Stream Helpers](stream-helpers.md)
@@ -0,0 +1,459 @@
# URI Helpers
This page covers this package's `Psr\Http\Message\UriInterface` implementation
and URI helper classes for classifying, composing, resolving, normalizing,
comparing, and safely modifying URIs.
Aside from the standard `Psr\Http\Message\UriInterface` implementation provided
by the `GuzzleHttp\Psr7\Uri` class, this library also provides additional static
methods for working with URIs.
## URI Types
An instance of `Psr\Http\Message\UriInterface` can either be an absolute URI or
a relative reference. An absolute URI has a scheme. A relative reference is used
to express a URI relative to another URI, the base URI. Relative references can
be divided into several forms according to
[RFC 3986 Section 4.2](https://datatracker.ietf.org/doc/html/rfc3986#section-4.2):
- network-path references, e.g. `//example.com/path`
- absolute-path references, e.g. `/path`
- relative-path references, e.g. `subpath`
The following methods can be used to identify the type of the URI.
### `GuzzleHttp\Psr7\Uri::isAbsolute`
`public static function isAbsolute(UriInterface $uri): bool`
Whether the URI is absolute, i.e. it has a scheme.
### `GuzzleHttp\Psr7\Uri::isNetworkPathReference`
`public static function isNetworkPathReference(UriInterface $uri): bool`
Whether the URI is a network-path reference. A relative reference that begins
with two slash characters is termed a network-path reference.
### `GuzzleHttp\Psr7\Uri::isAbsolutePathReference`
`public static function isAbsolutePathReference(UriInterface $uri): bool`
Whether the URI is an absolute-path reference. A relative reference that begins
with a single slash character is termed an absolute-path reference.
### `GuzzleHttp\Psr7\Uri::isRelativePathReference`
`public static function isRelativePathReference(UriInterface $uri): bool`
Whether the URI is a relative-path reference. A relative reference that does not
begin with a slash character is termed a relative-path reference.
### `GuzzleHttp\Psr7\Uri::isSameDocumentReference`
`public static function isSameDocumentReference(UriInterface $uri, ?UriInterface $base = null): bool`
Whether the URI is a same-document reference. A same-document reference refers
to a URI that is, aside from its fragment component, identical to the base URI.
When no base URI is given, only an empty URI reference (apart from its fragment)
is considered a same-document reference.
## URI Syntax Validation
`GuzzleHttp\Psr7\Rfc3986` provides static methods for validating and
canonicalizing individual URI components against the grammar defined by
[RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). They operate on raw
component strings rather than on `Psr\Http\Message\UriInterface` instances.
### `GuzzleHttp\Psr7\Rfc3986::isValidScheme`
`public static function isValidScheme(string $scheme): bool`
Whether the string is a valid URI scheme. Per
[RFC 3986 Section 3.1](https://datatracker.ietf.org/doc/html/rfc3986#section-3.1),
a scheme must start with a letter, followed by any number of letters, digits,
`+`, `-`, or `.`. The empty string is also accepted, since a URI reference may
omit the scheme.
### `GuzzleHttp\Psr7\Rfc3986::isValidHost`
`public static function isValidHost(string $host): bool`
Whether the string is a valid URI host. Per
[RFC 3986 Section 3.2.2](https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.2),
the host is an IP-literal, IPv4 address, or registered name. An empty host is
accepted, since the authority, and thus the host, may be empty. Bracketed values
are validated as IPv6 or IPvFuture literals; any other value is rejected if it
contains control characters, whitespace, an authority or path delimiter (`/`,
`?`, `#`, `@`, `\`), or an embedded colon denoting a port. Percent-encoding is
validated the same way: malformed sequences (a `%` not followed by two hex
digits) and percent-encoded octets that decode to one of the rejected bytes, to
a bracket (`[`, `]`), or to `%` itself are invalid, while all other
percent-encoded octets are accepted. Rejecting these percent-encoded octets is a
deliberate guzzle host policy, stricter than the RFC 3986 `reg-name` grammar,
which permits any well-formed `pct-encoded` octet; it matches the stricter
policy used throughout the library. RFC 6874 IPv6 zone identifiers (for example
`[fe80::1%25eth0]`) are not supported.
Registered names are otherwise intentionally permissive: single-label hosts such
as `localhost`, underscores, sub-delims, and raw or percent-encoded non-ASCII
(IDN) data are accepted and preserved as given, with no punycode conversion.
IDNA is treated as a client concern. Consumers that need DNS IDNs must perform
the conversion themselves, for example via Guzzle's `idn_conversion` request
option.
Validity here is a statement about URI syntax only. It does not imply that the
host resolves or that a client will interpret it exactly as written. A PSR-7 URI
object does not know which resolver or transport will consume it, so it cannot
establish destination identity. Consumers that restrict the destination must
enforce that policy at the client or transport boundary and ensure the
connection uses an approved address; checking the spelling or a separate DNS
lookup is insufficient.
### `GuzzleHttp\Psr7\Rfc3986::isValidPort`
`public static function isValidPort(string $port): bool`
Whether the string is a valid port number. RFC 3986 defines the port as
`*DIGIT`, which also permits an empty port and has no upper bound; this applies
the stricter policy used throughout the library instead, accepting a non-empty
run of digits (leading zeros are accepted and normalized) that resolves to a
value in the range 0-65535.
### `GuzzleHttp\Psr7\Rfc3986::canonicalizeIpv6`
`public static function canonicalizeIpv6(string $address): string`
Returns the [RFC 5952](https://datatracker.ietf.org/doc/html/rfc5952#section-4)
canonical form of a valid IPv6 address. The address must be a valid textual
IPv6 address without brackets and without a zone identifier, such as the
inside of an IP-literal accepted by `isValidHost()`. Canonicalization
lowercases the hexadecimal fields, suppresses leading zeros, and collapses the
longest run of two or more zero fields (the leftmost on a tie) with `::`.
Embedded dotted-decimal notation follows the rendering policy of BIND-derived
`inet_ntop()` implementations and curl: exactly the IPv4-mapped
(`::ffff:0:0/96`) and deprecated IPv4-compatible (`::/96`) layouts use it,
while other embedded-IPv4 forms, including translated (NAT64) well-known
prefixes such as `64:ff9b::/96` (RFC 6052), serialize in pure hexadecimal
fields. An `\InvalidArgumentException` is thrown if the address cannot be
parsed.
Validation is strict and platform-independent: the address is checked against
the RFC 3986 `IPv6address` grammar with PHP's `FILTER_VALIDATE_IP` filter and
parsed in pure PHP, so spellings that only some platform parsers accept, such as
the zero-padded dotted octets in `::ffff:192.168.001.001`, are rejected
everywhere.
Emitting dotted-decimal notation for these two selected layouts is the
BIND-derived `inet_ntop()` and curl compatibility policy used here.
[RFC 5952 Section 5](https://datatracker.ietf.org/doc/html/rfc5952#section-5)
permits mixed notation for recognizable embedded-IPv4 prefixes but does not
limit that category to these layouts; RFC 6052, for example, defines
`64:ff9b::/96` as a Well-Known Prefix, which this implementation renders in
pure hexadecimal. The WHATWG URL Standard always emits pure hexadecimal
fields.
## URI Components
Additional methods to work with URI components.
### `GuzzleHttp\Psr7\Uri::isDefaultPort`
`public static function isDefaultPort(UriInterface $uri): bool`
Whether the URI has the default port of the current scheme.
`Psr\Http\Message\UriInterface::getPort` may return null or the standard port.
This method can be used independently of the implementation.
### `GuzzleHttp\Psr7\Uri::composeComponents`
`public static function composeComponents(?string $scheme, ?string $authority, string $path, ?string $query, ?string $fragment): string`
Composes a URI reference string from its various components according to
[RFC 3986 Section 5.3](https://datatracker.ietf.org/doc/html/rfc3986#section-5.3).
Usually this method does not need to be called manually but instead is used
indirectly via `Psr\Http\Message\UriInterface::__toString`.
PSR-7 UriInterface treats an empty component the same as a missing component as
`getQuery()`, `getFragment()` etc. always return a string. This explains the
slight difference to RFC 3986 Section 5.3.
Another adjustment is that the authority separator is added even when the
authority is missing/empty for the "file" scheme. This is because PHP stream
functions like `file_get_contents` only work with `file:///myfile` but not with
`file:/myfile` although they are equivalent according to RFC 3986. But
`file:///` is the more common syntax for the file scheme anyway (Chrome for
example redirects to that format). The separator is omitted when such a URI has
a rootless or empty path: adding it would turn the first path segment into the
authority of the composed URI, or compose the string `file://`, which cannot be
parsed back into a URI.
### `GuzzleHttp\Psr7\Uri::fromParts`
`public static function fromParts(array $parts): UriInterface`
Creates a URI from a hash of
[`parse_url`](https://www.php.net/manual/en/function.parse-url.php) components.
### `GuzzleHttp\Psr7\Uri::withQueryValue`
`public static function withQueryValue(UriInterface $uri, string $key, ?string $value): UriInterface`
Creates a new URI with a specific query string value. Any existing query string
values that exactly match the provided key are removed and replaced with the
given key value pair. A value of null will set the query string key without a
value, e.g. "key" instead of "key=value".
### `GuzzleHttp\Psr7\Uri::withQueryValues`
`public static function withQueryValues(UriInterface $uri, array $keyValueArray): UriInterface`
Creates a new URI with multiple query string values. It has the same behavior as
`withQueryValue()` but for an associative array of key => value.
### `GuzzleHttp\Psr7\Uri::withoutQueryValue`
`public static function withoutQueryValue(UriInterface $uri, string $key): UriInterface`
Creates a new URI with a specific query string value removed. Any existing query
string values that exactly match the provided key are removed.
## Cross-Origin Detection
`GuzzleHttp\Psr7\UriComparator` provides methods to determine if a modified URI
should be considered cross-origin.
### `GuzzleHttp\Psr7\UriComparator::isCrossOrigin`
`public static function isCrossOrigin(UriInterface $original, UriInterface $modified): bool`
Determines if a modified URI should be considered cross-origin with respect to
an original URI.
Two URIs are cross-origin when their scheme, host, or effective port differ.
Host comparison is case-insensitive, and bracketed IPv6 literals are
canonicalized to their RFC 5952 form from any PSR-7 implementation before
comparison, so equivalent spellings of the same address are same-origin.
IPvFuture literals and bracketed values that cannot be parsed as an IPv6
address, such as those carrying zone identifiers, still compare as
case-insensitive text. Missing ports use the default port for `http`, `https`,
`ws`, or `wss`. Other schemes do not receive implicit default ports.
This helper only compares URI origins. It does not implement redirect handling
or credential policy.
## Reference Resolution
`GuzzleHttp\Psr7\UriResolver` provides methods to resolve a URI reference in the
context of a base URI according to
[RFC 3986 Section 5](https://datatracker.ietf.org/doc/html/rfc3986#section-5).
This is also what web browsers do when resolving a link in a document based on
the current request URI.
### `GuzzleHttp\Psr7\UriResolver::resolve`
`public static function resolve(UriInterface $base, UriInterface $rel): UriInterface`
Converts the relative URI into a new URI that is resolved against the base URI.
When the resolved path is a relative-path reference whose first segment contains
a colon, which would be mistaken for a scheme name (RFC 3986 Section 4.2), it is
prefixed with `./`, e.g. `./a:b`.
### `GuzzleHttp\Psr7\UriResolver::removeDotSegments`
`public static function removeDotSegments(string $path): string`
Removes dot segments from a path and returns the new path according to
[RFC 3986 Section 5.2.4](https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4).
Excess `..` segments above the root of an absolute path are dropped without
consuming the root, so the result can begin with `//` (e.g. `/..//a` becomes
`//a`). Such a path is not valid for a URI without an authority (RFC 3986
Section 3.3); `resolve()` and `UriNormalizer::normalize()` serialize it with a
`/.` prefix in that case, like the WHATWG URL Standard.
### `GuzzleHttp\Psr7\UriResolver::relativize`
`public static function relativize(UriInterface $base, UriInterface $target): UriInterface`
Returns the target URI as a relative reference from the base URI. This method is
the counterpart to `resolve()`:
```php
(string) $target === (string) UriResolver::resolve($base, UriResolver::relativize($base, $target))
```
One use case is to use the current request URI as the base URI and then generate
relative links in your documents to reduce the document size or offer
self-contained downloadable document archives.
```php
$base = new Uri('http://example.com/a/b/');
echo UriResolver::relativize($base, new Uri('http://example.com/a/b/c')); // prints 'c'.
echo UriResolver::relativize($base, new Uri('http://example.com/a/x/y')); // prints '../x/y'.
echo UriResolver::relativize($base, new Uri('http://example.com/a/b/?q')); // prints '?q'.
echo UriResolver::relativize($base, new Uri('http://example.org/a/b/')); // prints '//example.org/a/b/'.
echo UriResolver::relativize($base, new Uri('http://example.com')); // prints '//example.com'.
```
This method also accepts a target that is already relative and will try to
relativize it further. Only a relative-path reference will be returned as-is.
```php
echo UriResolver::relativize($base, new Uri('/a/b/c')); // prints 'c' as well
```
## Normalization and Comparison
`GuzzleHttp\Psr7\UriNormalizer` provides methods to normalize and compare URIs
according to
[RFC 3986 Section 6](https://datatracker.ietf.org/doc/html/rfc3986#section-6).
### `GuzzleHttp\Psr7\UriNormalizer::normalize`
`public static function normalize(UriInterface $uri, int $flags = self::PRESERVING_NORMALIZATIONS): UriInterface`
Returns a normalized URI. The scheme and host component are already normalized
to lowercase per PSR-7 UriInterface. This method adds additional normalizations
that can be configured with the `$flags` parameter, which is a bitmask of
normalizations to apply.
PSR-7 UriInterface cannot distinguish between an empty component and a missing
component as `getQuery()`, `getFragment()` etc. always return a string. This
means the URIs `/?#` and `/` are treated equivalent which is not necessarily
true according to RFC 3986. But that difference is highly uncommon in reality.
So this potential normalization is implied in PSR-7 as well.
A path the URI cannot hold, such as a `//`-leading path without an authority or
a relative-path reference whose first segment contains a colon, is prefixed with
`/.` or `./` respectively instead of throwing, as `UriResolver::resolve()` does.
The percent-encoding normalizations only do so where they rewrote the path. For
example, decoding `a%41:` yields `./aA:`, since `aA:` would be an absolute URI
with the scheme `aa`.
The following normalizations are available:
- `UriNormalizer::PRESERVING_NORMALIZATIONS`
Default normalizations which only include the ones that preserve semantics.
- `UriNormalizer::CAPITALIZE_PERCENT_ENCODING`
All letters within a percent-encoding triplet (e.g., "%3A") are
case-insensitive, and should be capitalized. This applies to the userinfo,
host, path, query, and fragment components. Bracketed IP-literal hosts are
skipped as a legacy tolerance for nonstandard values other implementations
may carry; zone-identifier text was briefly valid URI syntax under RFC 6874,
which RFC 9844 obsoleted and reverted. The userinfo and host are only
rewritten when the value returned by the implementation matches the
normalized form, and a userinfo with an empty user segment is never
rewritten. No percent-encoding normalization is applied to a component that
contains malformed percent syntax, such as a `%` not followed by two
hexadecimal digits.
Example: `http://example.org/a%c2%b1b` → `http://example.org/a%C2%B1b`
- `UriNormalizer::DECODE_UNRESERVED_CHARACTERS`
Decodes percent-encoded octets of unreserved characters. For consistency,
percent-encoded octets in the ranges of ALPHA (%41–%5A and %61–%7A), DIGIT
(%30–%39), hyphen (%2D), period (%2E), underscore (%5F), or tilde (%7E)
should not be created by URI producers and, when found in a URI, should be
decoded to their corresponding unreserved characters by URI normalizers.
This applies to the userinfo, host, path, query, and fragment components.
Since the host is case-insensitive and PSR-7 requires it to be lowercase,
octets decoded in the host are lowercased (e.g., "%41" becomes "a").
Bracketed IP-literal hosts are skipped as a legacy tolerance for nonstandard
values other implementations may carry; zone-identifier text was briefly
valid URI syntax under RFC 6874, which RFC 9844 obsoleted and reverted. The
userinfo and host are only rewritten when the value returned by the
implementation matches the normalized form, and a userinfo with an empty
user segment is never rewritten. No percent-encoding normalization is
applied to a component that contains malformed percent syntax, such as a `%`
not followed by two hexadecimal digits.
Example: `http://example.org/%7Eusern%61me/` → `http://example.org/~username/`
- `UriNormalizer::CONVERT_EMPTY_PATH`
Converts the empty path to "/" for http and https URIs.
Example: `http://example.org` → `http://example.org/`
- `UriNormalizer::REMOVE_DEFAULT_HOST`
Removes the default host of the given URI scheme from the URI. Only the
"file" scheme defines the default host "localhost". All of `file:/myfile`,
`file:///myfile`, and `file://localhost/myfile` are equivalent according to
RFC 3986.
When removing the host leaves a URI without an authority whose path begins
with `//`, the path is serialized with a `/.` prefix.
Example: `file://localhost/myfile` → `file:///myfile`,
`file://localhost//x` → `file:///.//x`
- `UriNormalizer::REMOVE_DEFAULT_PORT`
Removes the default port of the given URI scheme from the URI.
Example: `http://example.org:80/` → `http://example.org/`
- `UriNormalizer::REMOVE_DOT_SEGMENTS`
Removes unnecessary dot-segments. Dot-segments in relative-path references
are not removed as it would change the semantics of the URI reference.
Example: `http://example.org/../a/b/../c/./d.html` → `http://example.org/a/c/d.html`
- `UriNormalizer::REMOVE_DUPLICATE_SLASHES`
Paths which include two or more adjacent slashes are converted to one.
Webservers usually ignore duplicate slashes and treat those URIs equivalent.
But in theory those URIs do not need to be equivalent. So this normalization
may change the semantics. Encoded slashes (%2F) are not removed.
Example: `http://example.org//foo///bar.html` → `http://example.org/foo/bar.html`
- `UriNormalizer::SORT_QUERY_PARAMETERS`
Sort query parameters with their values in alphabetical order. However, the
order of parameters in a URI may be significant (this is not defined by the
standard). So this normalization is not safe and may change the semantics of
the URI.
Example: `?lang=en&article=fred` → `?article=fred&lang=en`
- `UriNormalizer::CANONICALIZE_IPV6_HOST`
Canonicalizes IPv6 hosts to their RFC 5952 form. IPv6 addresses allow
leading zeros and multiple placements of the `::` elision, so the same
address has many textual spellings. The canonical form is required for
IPv6 literals in URIs by RFC 5952 Section 6 and never changes what the URI
refers to. Native `Uri` instances already guarantee canonical output; for
other implementations, the canonical host is requested through
`withHost()` and the result is kept only when the returned `getHost()`
exactly matches the requested spelling, otherwise this step leaves the URI
unchanged while other selected normalizations still apply, and setter
exceptions propagate.
Example: `http://[::0:0a]/` → `http://[::a]/`
### `GuzzleHttp\Psr7\UriNormalizer::isEquivalent`
`public static function isEquivalent(UriInterface $uri1, UriInterface $uri2, int $normalizations = self::PRESERVING_NORMALIZATIONS): bool`
Whether two URIs can be considered equivalent. Both URIs are normalized
automatically before comparison with the given `$normalizations` bitmask. The
method also accepts relative URI references and returns true when they are
equivalent. This of course assumes they will be resolved against the same base
URI. If this is not the case, determination of equivalence or difference of
relative references does not mean anything.
## Related
- [PSR-7 Messages](psr-7-messages.md)
- [Streams and Decorators](streams-and-decorators.md)
- [URI and MIME Helpers](uri-and-mime-helpers.md)
- [Header and Query Helpers](header-and-query-helpers.md)
+42 -35
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use Psr\Http\Message\StreamInterface;
/**
@@ -13,17 +14,16 @@ use Psr\Http\Message\StreamInterface;
*/
final class AppendStream implements StreamInterface
{
use NonSerializableStreamTrait;
/** @var StreamInterface[] Streams being decorated */
private $streams = [];
private array $streams = [];
/** @var bool */
private $seekable = true;
private bool $seekable = true;
/** @var int */
private $current = 0;
private int $current = 0;
/** @var int */
private $pos = 0;
private int $pos = 0;
/**
* @param StreamInterface[] $streams Streams to decorate. Each stream must
@@ -38,16 +38,9 @@ final class AppendStream implements StreamInterface
public function __toString(): string
{
try {
$this->rewind();
return $this->getContents();
} catch (\Throwable $e) {
if (\PHP_VERSION_ID >= 70400) {
throw $e;
}
trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR);
return '';
}
$this->rewind();
return $this->getContents();
}
/**
@@ -130,7 +123,7 @@ final class AppendStream implements StreamInterface
if ($s === null) {
return null;
}
$size += $s;
$size = Integers::add($size, $s);
}
return $size;
@@ -138,9 +131,9 @@ final class AppendStream implements StreamInterface
public function eof(): bool
{
return !$this->streams ||
($this->current >= count($this->streams) - 1 &&
$this->streams[$this->current]->eof());
return !$this->streams
|| ($this->current >= count($this->streams) - 1
&& $this->streams[$this->current]->eof());
}
public function rewind(): void
@@ -151,7 +144,7 @@ final class AppendStream implements StreamInterface
/**
* Attempts to seek to the given position. Only supports SEEK_SET.
*/
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if (!$this->seekable) {
throw new \RuntimeException('This AppendStream is not seekable');
@@ -167,7 +160,7 @@ final class AppendStream implements StreamInterface
$stream->rewind();
} catch (\Exception $e) {
throw new \RuntimeException('Unable to seek stream '
. $i . ' of the AppendStream', 0, $e);
.$i.' of the AppendStream', 0, $e);
}
}
@@ -182,9 +175,20 @@ final class AppendStream implements StreamInterface
/**
* Reads from all of the appended streams until the length is met or EOF.
*
* A timeout after some bytes were read returns those bytes, and the next
* read waits on the same stream again.
*/
public function read($length): string
public function read(int $length): string
{
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
if ($this->streams === []) {
return '';
}
$buffer = '';
$total = count($this->streams) - 1;
$remaining = $length;
@@ -197,10 +201,18 @@ final class AppendStream implements StreamInterface
if ($this->current === $total) {
break;
}
$this->current++;
++$this->current;
}
$result = $this->streams[$this->current]->read($remaining);
try {
$result = StreamTimeout::read($this->streams[$this->current], $remaining, 'Unable to read from stream: timed out');
} catch (TimeoutException $e) {
if ($buffer === '') {
throw $e;
}
break;
}
if ($result === '') {
$progressToNext = true;
@@ -211,7 +223,7 @@ final class AppendStream implements StreamInterface
$remaining = $length - strlen($buffer);
}
$this->pos += strlen($buffer);
$this->pos = Integers::add($this->pos, strlen($buffer));
return $buffer;
}
@@ -231,18 +243,13 @@ final class AppendStream implements StreamInterface
return $this->seekable;
}
public function write($string): int
public function write(string $string): int
{
throw new \RuntimeException('Cannot write to an AppendStream');
}
/**
* {@inheritdoc}
*
* @return mixed
*/
public function getMetadata($key = null)
public function getMetadata(?string $key = null): ?array
{
return $key ? null : [];
return $key === null ? [] : null;
}
}
+17 -15
View File
@@ -16,22 +16,22 @@ use Psr\Http\Message\StreamInterface;
*/
final class BufferStream implements StreamInterface
{
/** @var int */
private $hwm;
use NonSerializableStreamTrait;
/** @var string */
private $buffer = '';
private int $hwm;
private string $buffer = '';
/**
* @param int $hwm High water mark, representing the preferred maximum
* buffer size. If the size of the buffer exceeds the high
* water mark, then calls to write will continue to succeed
* but will return 0 to inform writers to slow down
* buffer size. If the size of the buffer reaches or exceeds
* the high water mark, then calls to write will continue to
* succeed but will return 0 to inform writers to slow down
* until the buffer has been drained by reading from it.
*/
public function __construct(int $hwm = 16384)
{
$this->hwm = $hwm;
$this->hwm = Integers::assertNonNegativeInteger($hwm, 'High water mark');
}
public function __toString(): string
@@ -84,7 +84,7 @@ final class BufferStream implements StreamInterface
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
throw new \RuntimeException('Cannot seek a BufferStream');
}
@@ -102,8 +102,12 @@ final class BufferStream implements StreamInterface
/**
* Reads data from the buffer.
*/
public function read($length): string
public function read(int $length): string
{
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
$currentLength = strlen($this->buffer);
if ($length >= $currentLength) {
@@ -122,7 +126,7 @@ final class BufferStream implements StreamInterface
/**
* Writes data to the buffer.
*/
public function write($string): int
public function write(string $string): int
{
$this->buffer .= $string;
@@ -134,16 +138,14 @@ final class BufferStream implements StreamInterface
}
/**
* {@inheritdoc}
*
* @return mixed
*/
public function getMetadata($key = null)
public function getMetadata(?string $key = null)
{
if ($key === 'hwm') {
return $this->hwm;
}
return $key ? null : [];
return $key === null ? [] : null;
}
}
+109 -23
View File
@@ -13,27 +13,34 @@ use Psr\Http\Message\StreamInterface;
final class CachingStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var StreamInterface Stream being wrapped */
private $remoteStream;
private StreamInterface $remoteStream;
/** @var int Number of bytes to skip reading due to a write on the buffer */
private $skipReadBytes = 0;
private int $skipReadBytes = 0;
/**
* @var StreamInterface
*/
private $stream;
private StreamInterface $stream;
private bool $detached = false;
private bool $closed = false;
/**
* We will treat the buffer object as the body of the stream
*
* @param StreamInterface $stream Stream to cache. The cursor is assumed to be at the beginning of the stream.
* @param StreamInterface $target Optionally specify where data is cached
* @param StreamInterface $target Optionally specify where data is cached. Defaults to a "php://temp"
* stream. A custom target is used as a random-access byte buffer to
* replay the remote stream, so it must be readable, writable, and
* seekable, report an accurate position and size, and store writes
* losslessly. Lossy or non-seekable streams such as BufferStream and
* DroppingStream are not valid targets.
*/
public function __construct(
StreamInterface $stream,
StreamInterface $target = null
?StreamInterface $target = null
) {
$this->remoteStream = $stream;
$this->stream = $target ?: new Stream(Utils::tryFopen('php://temp', 'r+'));
@@ -41,6 +48,10 @@ final class CachingStream implements StreamInterface
public function getSize(): ?int
{
if ($this->detached) {
return null;
}
$remoteSize = $this->remoteStream->getSize();
if (null === $remoteSize) {
@@ -55,18 +66,31 @@ final class CachingStream implements StreamInterface
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if ($whence === SEEK_SET) {
$byte = $offset;
} elseif ($whence === SEEK_CUR) {
$byte = $offset + $this->tell();
$byte = Integers::addSigned($this->tell(), $offset);
} elseif ($whence === SEEK_END) {
$size = $this->remoteStream->getSize();
if ($size === null) {
// Discovering the size reads the remote stream to EOF and
// moves the cursor, so restore the cursor if the computed
// target is rejected to keep a failed seek side-effect free.
$position = $this->tell();
$size = $this->cacheEntireStream();
try {
$byte = Integers::addSigned($size, $offset);
} catch (\Throwable $e) {
$this->stream->seek($position);
throw $e;
}
} else {
$byte = Integers::addSigned($size, $offset);
}
$byte = $size + $offset;
} else {
throw new \InvalidArgumentException('Invalid whence');
}
@@ -77,8 +101,16 @@ final class CachingStream implements StreamInterface
// Read the remoteStream until we have read in at least the amount
// of bytes requested, or we reach the end of the file.
while ($diff > 0 && !$this->remoteStream->eof()) {
$this->read($diff);
$diff = $byte - $this->stream->getSize();
$previousSize = $this->stream->getSize();
$previousSkipReadBytes = $this->skipReadBytes;
$data = $this->read($diff);
$currentSize = $this->stream->getSize();
if ($data === '' && $currentSize === $previousSize && $this->skipReadBytes === $previousSkipReadBytes) {
break;
}
$diff = $byte - $currentSize;
}
} else {
// We can just do a normal seek since we've already seen this byte.
@@ -86,8 +118,12 @@ final class CachingStream implements StreamInterface
}
}
public function read($length): string
public function read(int $length): string
{
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
// Perform a regular read on any previously read data from the buffer
$data = $this->stream->read($length);
$remaining = $length - strlen($data);
@@ -98,8 +134,10 @@ final class CachingStream implements StreamInterface
// been filled from the remote stream, then we must skip bytes on
// the remote stream to emulate overwriting bytes from that
// position. This mimics the behavior of other PHP stream wrappers.
$remoteData = $this->remoteStream->read(
$remaining + $this->skipReadBytes
$remoteData = StreamTimeout::read(
$this->remoteStream,
Integers::add($remaining, $this->skipReadBytes),
'Unable to read from stream: timed out'
);
if ($this->skipReadBytes) {
@@ -109,21 +147,25 @@ final class CachingStream implements StreamInterface
}
$data .= $remoteData;
$this->stream->write($remoteData);
// A short cache write would silently corrupt later replays, so fail loudly.
if ($this->stream->write($remoteData) !== strlen($remoteData)) {
throw new \RuntimeException('Unable to cache the entire read from the remote stream');
}
}
return $data;
}
public function write($string): int
public function write(string $string): int
{
// When appending to the end of the currently read stream, you'll want
// to skip bytes from being read from the remote stream to emulate
// other stream wrappers. Basically replacing bytes of data of a fixed
// length.
$overflow = (strlen($string) + $this->tell()) - $this->remoteStream->tell();
$overflow = Integers::add(strlen($string), $this->tell()) - $this->remoteStream->tell();
if ($overflow > 0) {
$this->skipReadBytes += $overflow;
$this->skipReadBytes = max($this->skipReadBytes, $overflow);
}
return $this->stream->write($string);
@@ -134,13 +176,57 @@ final class CachingStream implements StreamInterface
return $this->stream->eof() && $this->remoteStream->eof();
}
public function detach()
{
if ($this->detached) {
return null;
}
$position = $this->tell();
$this->cacheEntireStream();
$this->stream->seek($position);
$resource = $this->stream->detach();
$this->detached = true;
return $resource;
}
/**
* Close both the remote stream and buffer stream
* Close the remote stream and any attached cache stream.
*/
public function close(): void
{
$this->remoteStream->close();
$this->stream->close();
if ($this->closed) {
return;
}
$closeCache = !$this->detached;
$this->closed = true;
$this->detached = true;
$exception = null;
try {
$this->remoteStream->close();
} catch (\Throwable $e) {
$exception = $e;
}
if ($closeCache) {
try {
$this->stream->close();
} catch (\Throwable $e) {
if ($exception === null) {
$exception = $e;
}
}
}
if ($exception !== null) {
throw $exception;
}
}
private function cacheEntireStream(): int
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* Escapes control characters and malformed UTF-8 for use in diagnostics.
*/
final class DiagnosticValue
{
private function __construct()
{
}
/**
* Escapes C0, DEL, and C1 controls as uppercase `\xNN` sequences.
*
* ASCII bytes from 0x20 through 0x7E and valid UTF-8 characters outside
* those control ranges remain unchanged. If the input is malformed UTF-8 or
* PCRE cannot process it, every byte outside printable ASCII is escaped.
* Valid C1 characters are rendered as `\xNN` using their Unicode code
* points. During bytewise fallback, each original byte outside printable
* ASCII is rendered in the same form. The result is diagnostic text, not a
* reversible encoding.
*
* This does not encode values for HTML, JSON, shells, terminals, URLs, or
* protocol fields.
*/
public static function escape(string $value): string
{
$escaped = \preg_replace_callback(
'/[\x{0000}-\x{001F}\x{007F}-\x{009F}]/u',
static function (array $matches): string {
$character = $matches[0];
$codePoint = \strlen($character) === 1 ? \ord($character) : \ord($character[1]);
return \sprintf('\\x%02X', $codePoint);
},
$value
);
return $escaped ?? self::escapeBytes($value);
}
private static function escapeBytes(string $value): string
{
$escaped = '';
for ($offset = 0, $length = \strlen($value); $offset < $length; ++$offset) {
$byte = \ord($value[$offset]);
if ($byte >= 0x20 && $byte <= 0x7E) {
$escaped .= $value[$offset];
continue;
}
$escaped .= \sprintf('\\x%02X', $byte);
}
return $escaped;
}
}
@@ -13,12 +13,11 @@ use Psr\Http\Message\StreamInterface;
final class DroppingStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var int */
private $maxLength;
private int $maxLength;
/** @var StreamInterface */
private $stream;
private StreamInterface $stream;
/**
* @param StreamInterface $stream Underlying stream to decorate.
@@ -27,10 +26,10 @@ final class DroppingStream implements StreamInterface
public function __construct(StreamInterface $stream, int $maxLength)
{
$this->stream = $stream;
$this->maxLength = $maxLength;
$this->maxLength = Integers::assertNonNegativeInteger($maxLength, 'Maximum length');
}
public function write($string): int
public function write(string $string): int
{
$diff = $this->maxLength - $this->stream->getSize();
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7\Exception;
use RuntimeException;
/**
* Exception thrown when a stream operation times out.
*/
class TimeoutException extends RuntimeException
{
}
+113 -43
View File
@@ -7,7 +7,7 @@ namespace GuzzleHttp\Psr7;
use Psr\Http\Message\StreamInterface;
/**
* Compose stream implementations based on a hash of functions.
* Compose stream implementations based on a hash of callables.
*
* Allows for easy testing and extension of a provided stream without needing
* to create a concrete class for a simple extension point.
@@ -15,14 +15,18 @@ use Psr\Http\Message\StreamInterface;
#[\AllowDynamicProperties]
final class FnStream implements StreamInterface
{
use NonSerializableStreamTrait;
private const SLOTS = [
'__toString', 'close', 'detach', 'rewind',
'getSize', 'tell', 'eof', 'isSeekable', 'seek', 'isWritable', 'write',
'isReadable', 'read', 'getContents', 'getMetadata'
'isReadable', 'read', 'getContents', 'getMetadata',
];
/** @var array<string, callable> */
private $methods;
private array $methods;
private bool $detached = false;
/**
* @param array<string, callable> $methods Hash of method name to a callable.
@@ -31,9 +35,9 @@ final class FnStream implements StreamInterface
{
$this->methods = $methods;
// Create the functions on the class
// Create the callables on the class
foreach ($methods as $name => $fn) {
$this->{'_fn_' . $name} = $fn;
$this->{'_fn_'.$name} = $fn;
}
}
@@ -44,8 +48,7 @@ final class FnStream implements StreamInterface
*/
public function __get(string $name): void
{
throw new \BadMethodCallException(str_replace('_fn_', '', $name)
. '() is not implemented in the FnStream');
throw new \BadMethodCallException(\sprintf('%s() is not implemented in the FnStream', DiagnosticValue::escape(str_replace('_fn_', '', $name))));
}
/**
@@ -53,8 +56,14 @@ final class FnStream implements StreamInterface
*/
public function __destruct()
{
if (isset($this->_fn_close)) {
call_user_func($this->_fn_close);
if ($this->detached || !isset($this->_fn_close)) {
return;
}
try {
$this->close();
} catch (\Throwable $e) {
// Destructors must not surface cleanup failures.
}
}
@@ -65,7 +74,18 @@ final class FnStream implements StreamInterface
*/
public function __wakeup(): void
{
throw new \LogicException('FnStream should never be unserialized');
$this->methods = [];
$this->detached = true;
throw new \LogicException(static::class.' should never be unserialized');
}
public function __unserialize(array $data): void
{
$this->methods = [];
$this->detached = true;
throw new \LogicException(static::class.' should never be unserialized');
}
/**
@@ -73,11 +93,9 @@ final class FnStream implements StreamInterface
* specific method calls.
*
* @param StreamInterface $stream Stream to decorate
* @param array<string, callable> $methods Hash of method name to a closure
*
* @return FnStream
* @param array<string, callable> $methods Hash of method name to a callable
*/
public static function decorate(StreamInterface $stream, array $methods)
public static function decorate(StreamInterface $stream, array $methods): self
{
// If any of the required methods were not provided, then simply
// proxy to the decorated stream.
@@ -92,89 +110,141 @@ final class FnStream implements StreamInterface
public function __toString(): string
{
try {
return call_user_func($this->_fn___toString);
} catch (\Throwable $e) {
if (\PHP_VERSION_ID >= 70400) {
throw $e;
}
trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR);
return '';
}
$this->assertAttached();
/** @var string */
return ($this->_fn___toString)();
}
public function close(): void
{
call_user_func($this->_fn_close);
if ($this->detached) {
return;
}
$close = $this->_fn_close;
$this->detached = true;
$close();
}
public function detach()
{
return call_user_func($this->_fn_detach);
if ($this->detached) {
return null;
}
$detach = $this->_fn_detach;
$result = $detach();
$this->detached = true;
return $result;
}
public function getSize(): ?int
{
return call_user_func($this->_fn_getSize);
if ($this->detached) {
return null;
}
return ($this->_fn_getSize)();
}
public function tell(): int
{
return call_user_func($this->_fn_tell);
$this->assertAttached();
return ($this->_fn_tell)();
}
public function eof(): bool
{
return call_user_func($this->_fn_eof);
$this->assertAttached();
return ($this->_fn_eof)();
}
public function isSeekable(): bool
{
return call_user_func($this->_fn_isSeekable);
if ($this->detached) {
return false;
}
return ($this->_fn_isSeekable)();
}
public function rewind(): void
{
call_user_func($this->_fn_rewind);
$this->assertAttached();
($this->_fn_rewind)();
}
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
call_user_func($this->_fn_seek, $offset, $whence);
$this->assertAttached();
($this->_fn_seek)($offset, $whence);
}
public function isWritable(): bool
{
return call_user_func($this->_fn_isWritable);
if ($this->detached) {
return false;
}
return ($this->_fn_isWritable)();
}
public function write($string): int
public function write(string $string): int
{
return call_user_func($this->_fn_write, $string);
$this->assertAttached();
return ($this->_fn_write)($string);
}
public function isReadable(): bool
{
return call_user_func($this->_fn_isReadable);
if ($this->detached) {
return false;
}
return ($this->_fn_isReadable)();
}
public function read($length): string
public function read(int $length): string
{
return call_user_func($this->_fn_read, $length);
$this->assertAttached();
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
return ($this->_fn_read)($length);
}
public function getContents(): string
{
return call_user_func($this->_fn_getContents);
$this->assertAttached();
return ($this->_fn_getContents)();
}
/**
* {@inheritdoc}
*
* @return mixed
*/
public function getMetadata($key = null)
public function getMetadata(?string $key = null)
{
return call_user_func($this->_fn_getMetadata, $key);
if ($this->detached) {
return $key === null ? [] : null;
}
return ($this->_fn_getMetadata)($key);
}
private function assertAttached(): void
{
if ($this->detached) {
throw new \RuntimeException('Stream is detached');
}
}
}
+60 -26
View File
@@ -6,11 +6,14 @@ namespace GuzzleHttp\Psr7;
final class Header
{
private function __construct()
{
}
/**
* Parse an array of header values containing ";" separated data into an
* array of associative arrays representing the header key value pair data
* of the header. When a parameter does not contain a value, but just
* contains a key, this function will inject a key with a '' string value.
* Parses semicolon-separated header parameters into associative arrays, one
* per comma-separated header value. Parameters without a value are appended
* as values under integer keys.
*
* @param string|array $header Header to parse into components.
*/
@@ -22,8 +25,14 @@ final class Header
foreach ((array) $header as $value) {
foreach (self::splitList($value) as $val) {
$part = [];
foreach (preg_split('/;(?=([^"]*"[^"]*")*[^"]*$)/', $val) as $kvp) {
if (preg_match_all('/<[^>]+>|[^=]+/', $kvp, $matches)) {
foreach (self::splitParameters($val) as $kvp) {
$count = preg_match_all('/<[^>]+>|[^=]+/', $kvp, $matches);
if ($count === false) {
throw new \RuntimeException('Unable to parse header parameters: '.preg_last_error_msg());
}
if ($count !== 0) {
$m = $matches[0];
if (isset($m[1])) {
$part[trim($m[0], $trimmed)] = trim($m[1], $trimmed);
@@ -42,35 +51,60 @@ final class Header
}
/**
* Converts an array of header values that may contain comma separated
* headers into an array of headers with no comma separated values.
* Split a header value into semicolon-separated parameters.
*
* @param string|array $header Header to normalize.
*
* @deprecated Use self::splitList() instead.
* @return string[]
*/
public static function normalize($header): array
private static function splitParameters(string $value): array
{
$result = [];
foreach ((array) $header as $value) {
foreach (self::splitList($value) as $parsed) {
$result[] = $parsed;
$values = [];
$start = 0;
$isQuoted = false;
$isEscaped = false;
for ($i = 0, $max = \strlen($value); $i < $max; ++$i) {
$char = $value[$i];
if ($isEscaped) {
$isEscaped = false;
continue;
}
if ($isQuoted && $char === '\\') {
$isEscaped = true;
continue;
}
if ($char === '"') {
$isQuoted = !$isQuoted;
continue;
}
if (!$isQuoted && $char === ';') {
$values[] = \substr($value, $start, $i - $start);
$start = $i + 1;
}
}
return $result;
$values[] = \substr($value, $start);
return $values;
}
/**
* Splits a HTTP header defined to contain a comma-separated list into
* each individual value. Empty values will be removed.
* Splits an HTTP header defined to contain a comma-separated list into each
* individual value. Empty values are removed.
*
* Example headers include 'accept', 'cache-control' and 'if-none-match'.
* Example headers include `accept`, `cache-control`, and `if-none-match`.
*
* This method must not be used to parse headers that are not defined as
* a list, such as 'user-agent' or 'set-cookie'.
* This method must not be used to parse headers that are not defined as a
* list, such as `user-agent` or `set-cookie`.
*
* @param string|string[] $values Header value as returned by MessageInterface::getHeader()
* @param string|string[] $values Header value as returned by
* MessageInterface::getHeader()
*
* @return string[]
*/
@@ -89,7 +123,7 @@ final class Header
$v = '';
$isQuoted = false;
$isEscaped = false;
for ($i = 0, $max = \strlen($value); $i < $max; $i++) {
for ($i = 0, $max = \strlen($value); $i < $max; ++$i) {
if ($isEscaped) {
$v .= $value[$i];
$isEscaped = false;
@@ -98,7 +132,7 @@ final class Header
}
if (!$isQuoted && $value[$i] === ',') {
$v = \trim($v);
$v = \trim($v, " \t\n\r");
if ($v !== '') {
$result[] = $v;
}
@@ -123,7 +157,7 @@ final class Header
$v .= $value[$i];
}
$v = \trim($v);
$v = \trim($v, " \t\n\r");
if ($v !== '') {
$result[] = $v;
}
+18 -14
View File
@@ -23,26 +23,26 @@ use Psr\Http\Message\UriInterface;
* Note: in consuming code it is recommended to require the implemented interfaces
* and inject the instance of this class multiple times.
*/
final class HttpFactory implements
RequestFactoryInterface,
ResponseFactoryInterface,
ServerRequestFactoryInterface,
StreamFactoryInterface,
UploadedFileFactoryInterface,
UriFactoryInterface
final class HttpFactory implements RequestFactoryInterface, ResponseFactoryInterface, ServerRequestFactoryInterface, StreamFactoryInterface, UploadedFileFactoryInterface, UriFactoryInterface
{
public function createUploadedFile(
StreamInterface $stream,
int $size = null,
?int $size = null,
int $error = \UPLOAD_ERR_OK,
string $clientFilename = null,
string $clientMediaType = null
?string $clientFilename = null,
?string $clientMediaType = null
): UploadedFileInterface {
if ($size === null) {
$size = $stream->getSize();
}
return new UploadedFile($stream, $size, $error, $clientFilename, $clientMediaType);
return new UploadedFile(
$stream,
Integers::assertOptionalNonNegativeSize($size, 'Uploaded file size'),
$error,
$clientFilename,
$clientMediaType
);
}
public function createStream(string $content = ''): StreamInterface
@@ -56,7 +56,7 @@ final class HttpFactory implements
$resource = Utils::tryFopen($file, $mode);
} catch (\RuntimeException $e) {
if ('' === $mode || false === \in_array($mode[0], ['r', 'w', 'a', 'x', 'c'], true)) {
throw new \InvalidArgumentException(sprintf('Invalid file opening mode "%s"', $mode), 0, $e);
throw new \InvalidArgumentException(sprintf('Invalid file opening mode: %s', DiagnosticValue::escape($mode)), 0, $e);
}
throw $e;
@@ -70,8 +70,12 @@ final class HttpFactory implements
return Utils::streamFor($resource);
}
public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface
{
public function createServerRequest(
string $method,
$uri,
#[\SensitiveParameter]
array $serverParams = []
): ServerRequestInterface {
if (empty($method)) {
if (!empty($serverParams['REQUEST_METHOD'])) {
$method = $serverParams['REQUEST_METHOD'];
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use Psr\Http\Message\StreamInterface;
/**
@@ -13,25 +14,116 @@ use Psr\Http\Message\StreamInterface;
* then appends the zlib.inflate filter. The stream is then converted back
* to a Guzzle stream resource to be used as a Guzzle stream.
*
* @link http://tools.ietf.org/html/rfc1950
* @link http://tools.ietf.org/html/rfc1952
* @link http://php.net/manual/en/filters.compression.php
* @see https://datatracker.ietf.org/doc/html/rfc1950
* @see https://datatracker.ietf.org/doc/html/rfc1952
* @see https://www.php.net/manual/en/filters.compression.php
*/
final class InflateStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var StreamInterface */
private $stream;
private StreamInterface $stream;
private ?StreamInterface $source;
private ?ReadFailureCapturingStream $capturingSource;
public function __construct(StreamInterface $stream)
{
$resource = StreamWrapper::getResource($stream);
$this->source = $stream;
$this->capturingSource = new ReadFailureCapturingStream($stream);
$resource = StreamWrapper::getResource($this->capturingSource);
// Specify window=15+32, so zlib will use header detection to both gzip (with header) and zlib data
// See http://www.zlib.net/manual.html#Advanced definition of inflateInit2
// See https://www.zlib.net/manual.html#Advanced definition of inflateInit2
// "Add 32 to windowBits to enable zlib and gzip decoding with automatic header detection"
// Default window size is 15.
stream_filter_append($resource, 'zlib.inflate', STREAM_FILTER_READ, ['window' => 15 + 32]);
$this->stream = $stream->isSeekable() ? new Stream($resource) : new NoSeekStream(new Stream($resource));
}
public function eof(): bool
{
return ($this->capturingSource === null || !$this->capturingSource->hasReadFailure()) && $this->stream->eof();
}
public function read(int $length): string
{
if ($length <= 0 || $this->source === null || $this->capturingSource === null) {
return $this->stream->read($length);
}
// A source failure that follows decoded bytes is reported by the next read.
$failure = $this->capturingSource->takeReadFailure();
if ($failure === null) {
try {
$data = $this->stream->read($length);
} catch (TimeoutException $e) {
throw $e;
} catch (\RuntimeException $e) {
if (StreamTimeout::isReadTimedOut($this->source)) {
throw new TimeoutException('Unable to read from stream: timed out', 0, $e);
}
throw $e;
}
if ($data !== '') {
return $data;
}
$failure = $this->capturingSource->takeReadFailure();
}
if ($failure instanceof TimeoutException || StreamTimeout::isReadTimedOut($this->source)) {
throw new TimeoutException('Unable to read from stream: timed out', 0, $failure);
}
if ($failure !== null) {
throw new \RuntimeException('Unable to read from stream', 0, $failure);
}
return '';
}
public function close(): void
{
$source = $this->source;
$this->source = null;
$this->capturingSource = null;
$exception = null;
try {
$this->stream->close();
} catch (\Throwable $e) {
$exception = $e;
}
if ($source !== null) {
try {
$source->close();
} catch (\Throwable $e) {
if ($exception === null) {
$exception = $e;
}
}
}
if ($exception !== null) {
throw $exception;
}
}
public function detach()
{
$this->source = null;
if ($this->capturingSource !== null) {
$this->capturingSource->stopCapturing();
$this->capturingSource = null;
}
return $this->stream->detach();
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* @internal
*/
final class Integers
{
private function __construct()
{
}
public static function add(int $a, int $b): int
{
if ($a < 0 || $b < 0) {
throw new \InvalidArgumentException('Integer operands must be non-negative');
}
if ($b > \PHP_INT_MAX - $a) {
throw new \OverflowException('Stream byte count exceeds the maximum integer size supported on this platform');
}
return $a + $b;
}
public static function addSigned(int $base, int $delta): int
{
if ($base < 0) {
throw new \InvalidArgumentException('Stream offset must be non-negative');
}
if ($delta > 0 && $delta > \PHP_INT_MAX - $base) {
throw new \OverflowException('Stream offset exceeds the maximum integer size supported on this platform');
}
$value = $base + $delta;
if ($value < 0) {
// A negative computed offset is a seek failure at runtime, so throw
// RuntimeException per PSR-7, unlike the precondition check above.
throw new \RuntimeException('Stream offset must be non-negative');
}
return $value;
}
/**
* @param mixed $value
*/
public static function assertEngineInteger($value, string $what): ?int
{
if ($value === false || $value === null) {
return null;
}
if (!\is_int($value) || $value < 0) {
throw new \OverflowException($what.' exceeds the maximum integer size supported on this platform');
}
return $value;
}
/**
* @param mixed $value
*/
public static function assertOptionalNonNegativeSize($value, string $name): ?int
{
if ($value === null) {
return null;
}
if (!\is_int($value) || $value < 0) {
throw new \InvalidArgumentException($name.' must be a non-negative integer or null');
}
return $value;
}
/**
* @param mixed $value
*/
public static function assertNonNegativeInteger($value, string $name): int
{
if (!\is_int($value) || $value < 0) {
throw new \InvalidArgumentException($name.' must be a non-negative integer');
}
return $value;
}
/**
* @param mixed $value
*/
public static function assertLimitInteger($value, string $name): int
{
if (!\is_int($value) || $value < -1) {
throw new \InvalidArgumentException($name.' must be -1 or a non-negative integer');
}
return $value;
}
}
@@ -13,17 +13,13 @@ use Psr\Http\Message\StreamInterface;
final class LazyOpenStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var string */
private $filename;
private string $filename;
/** @var string */
private $mode;
private string $mode;
/**
* @var StreamInterface
*/
private $stream;
private StreamInterface $stream;
/**
* @param string $filename File to lazily open
@@ -39,6 +35,13 @@ final class LazyOpenStream implements StreamInterface
unset($this->stream);
}
public function __unserialize(array $data): void
{
$this->stream = new BufferStream();
throw new \LogicException(static::class.' should never be unserialized');
}
/**
* Creates the underlying stream lazily when required.
*/
+64 -24
View File
@@ -12,15 +12,15 @@ use Psr\Http\Message\StreamInterface;
final class LimitStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var int Offset to start reading from */
private $offset;
private int $offset;
/** @var int Limit the number of bytes that can be read */
private $limit;
private int $limit;
/** @var StreamInterface */
private $stream;
private StreamInterface $stream;
/**
* @param StreamInterface $stream Stream to wrap
@@ -51,7 +51,7 @@ final class LimitStream implements StreamInterface
return false;
}
return $this->stream->tell() >= $this->offset + $this->limit;
return $this->stream->tell() >= Integers::add($this->offset, $this->limit);
}
/**
@@ -61,17 +61,21 @@ final class LimitStream implements StreamInterface
{
if (null === ($length = $this->stream->getSize())) {
return null;
} elseif ($this->limit === -1) {
return $length - $this->offset;
} else {
return min($this->limit, $length - $this->offset);
}
$size = $length - $this->offset;
if ($this->limit !== -1) {
$size = min($this->limit, $size);
}
return max(0, $size);
}
/**
* Allow for a bounded seek on the read limited stream
*/
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if ($whence !== SEEK_SET || $offset < 0) {
throw new \RuntimeException(sprintf(
@@ -81,11 +85,12 @@ final class LimitStream implements StreamInterface
));
}
$offset += $this->offset;
$offset = Integers::add($this->offset, $offset);
if ($this->limit !== -1) {
if ($offset > $this->offset + $this->limit) {
$offset = $this->offset + $this->limit;
$upperBound = Integers::add($this->offset, $this->limit);
if ($offset > $upperBound) {
$offset = $upperBound;
}
}
@@ -109,17 +114,48 @@ final class LimitStream implements StreamInterface
*/
public function setOffset(int $offset): void
{
$offset = Integers::assertNonNegativeInteger($offset, 'Offset');
$current = $this->stream->tell();
if ($current !== $offset) {
// If the stream cannot seek to the offset position, then read to it
if ($this->stream->isSeekable()) {
$this->stream->seek($offset);
} elseif ($current > $offset) {
throw new \RuntimeException("Could not seek to stream offset $offset");
} else {
$this->stream->read($offset - $current);
if ($current === $offset) {
$this->offset = $offset;
return;
}
// If the stream cannot seek to the offset position, then read to it.
if ($this->stream->isSeekable()) {
$this->stream->seek($offset);
$this->offset = $offset;
return;
}
if ($current > $offset) {
throw new \RuntimeException("Could not seek to stream offset $offset");
}
while ($current < $offset) {
if ($this->stream->eof()) {
$this->offset = $current;
return;
}
$result = $this->stream->read($offset - $current);
if ($result === '') {
if ($this->stream->eof()) {
$this->offset = $current;
return;
}
throw new \RuntimeException("Could not seek to stream offset $offset");
}
$current = Integers::add($current, strlen($result));
}
$this->offset = $offset;
@@ -134,18 +170,22 @@ final class LimitStream implements StreamInterface
*/
public function setLimit(int $limit): void
{
$this->limit = $limit;
$this->limit = Integers::assertLimitInteger($limit, 'Limit');
}
public function read($length): string
public function read(int $length): string
{
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
if ($this->limit === -1) {
return $this->stream->read($length);
}
// Check if the current position is less than the total allowed
// bytes + original offset
$remaining = ($this->offset + $this->limit) - $this->stream->tell();
$remaining = Integers::add($this->offset, $this->limit) - $this->stream->tell();
if ($remaining > 0) {
// Only return the amount of requested data, ensuring that the byte
// limit is not exceeded
+131 -121
View File
@@ -7,9 +7,16 @@ namespace GuzzleHttp\Psr7;
use Psr\Http\Message\MessageInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
final class Message
{
private const DEFAULT_BODY_SUMMARY_TRUNCATE_AT = 120;
private function __construct()
{
}
/**
* Returns the string representation of an HTTP message.
*
@@ -18,31 +25,48 @@ final class Message
public static function toString(MessageInterface $message): string
{
if ($message instanceof RequestInterface) {
$msg = trim($message->getMethod() . ' '
. $message->getRequestTarget())
. ' HTTP/' . $message->getProtocolVersion();
$msg = trim($message->getMethod().' '
.$message->getRequestTarget(), " \n\r\t\0\x0B")
.' HTTP/'.$message->getProtocolVersion();
if (!$message->hasHeader('host')) {
$msg .= "\r\nHost: " . $message->getUri()->getHost();
$msg .= "\r\nHost: ".self::hostHeaderFromUri($message->getUri());
}
} elseif ($message instanceof ResponseInterface) {
$msg = 'HTTP/' . $message->getProtocolVersion() . ' '
. $message->getStatusCode() . ' '
. $message->getReasonPhrase();
$msg = 'HTTP/'.$message->getProtocolVersion().' '
.$message->getStatusCode().' '
.$message->getReasonPhrase();
} else {
throw new \InvalidArgumentException('Unknown message type');
}
foreach ($message->getHeaders() as $name => $values) {
if (strtolower($name) === 'set-cookie') {
if (is_string($name) && Utils::asciiToLower($name) === 'set-cookie') {
foreach ($values as $value) {
$msg .= "\r\n{$name}: " . $value;
$msg .= "\r\n{$name}: ".$value;
}
} else {
$msg .= "\r\n{$name}: " . implode(', ', $values);
$msg .= "\r\n{$name}: ".implode(', ', $values);
}
}
return "{$msg}\r\n\r\n" . $message->getBody();
return "{$msg}\r\n\r\n".$message->getBody();
}
private static function hostHeaderFromUri(UriInterface $uri): string
{
$host = $uri->getHost();
if ($host === '') {
return '';
}
Uri::assertValidHost($host);
if (($port = $uri->getPort()) !== null) {
$host .= ':'.$port;
}
return $host;
}
/**
@@ -50,11 +74,17 @@ final class Message
*
* Will return `null` if the response is not printable.
*
* Reads seekable bodies from the beginning and restores the original cursor
* position before returning. Pass `null` for `$truncateAt` to use the
* default summary length.
*
* @param MessageInterface $message The message to get the body summary
* @param int $truncateAt The maximum allowed size of the summary
* @param int|null $truncateAt Maximum allowed size of the summary
*/
public static function bodySummary(MessageInterface $message, int $truncateAt = 120): ?string
public static function bodySummary(MessageInterface $message, ?int $truncateAt = null): ?string
{
$truncateAt ??= self::DEFAULT_BODY_SUMMARY_TRUNCATE_AT;
$body = $message->getBody();
if (!$body->isSeekable() || !$body->isReadable()) {
@@ -67,12 +97,21 @@ final class Message
return null;
}
$body->rewind();
$summary = $body->read($truncateAt);
$body->rewind();
$position = $body->tell();
if ($size > $truncateAt) {
$summary .= ' (truncated...)';
try {
$body->rewind();
$summary = $body->read($truncateAt);
if ($size > $truncateAt) {
if (preg_match('//u', $summary) !== 1) {
$summary = self::trimTrailingIncompleteUtf8Character($summary, $body->read(3));
}
$summary .= ' (truncated...)';
}
} finally {
$body->seek($position);
}
// Matches any printable character, including unicode characters:
@@ -84,6 +123,60 @@ final class Message
return $summary;
}
/**
* Trims a partial UTF-8 character from the end of a truncated string.
*/
private static function trimTrailingIncompleteUtf8Character(string $summary, string $lookahead): string
{
$length = strlen($summary);
if ($length === 0) {
return $summary;
}
$start = $length - 1;
while ($start >= 0) {
$byte = ord($summary[$start]);
if ($byte < 0x80 || $byte > 0xBF) {
break;
}
--$start;
}
if ($start < 0) {
return $summary;
}
$lead = ord($summary[$start]);
if ($lead >= 0xC2 && $lead <= 0xDF) {
$expectedLength = 2;
} elseif ($lead >= 0xE0 && $lead <= 0xEF) {
$expectedLength = 3;
} elseif ($lead >= 0xF0 && $lead <= 0xF4) {
$expectedLength = 4;
} else {
return $summary;
}
$availableLength = $length - $start;
if ($availableLength >= $expectedLength) {
return $summary;
}
$sequence = substr($summary, $start).substr($lookahead, 0, $expectedLength - $availableLength);
if (strlen($sequence) !== $expectedLength || preg_match('//u', $sequence) !== 1) {
return $summary;
}
return substr($summary, 0, $start);
}
/**
* Attempts to rewind a message body and throws an exception on failure.
*
@@ -106,117 +199,49 @@ final class Message
/**
* Parses an HTTP message into an associative array.
*
* The array contains the "start-line" key containing the start line of
* the message, "headers" key containing an associative array of header
* array values, and a "body" key containing the body of the message.
* The array contains the `start-line` key containing the start line of the
* message, `headers` key containing an associative array of header array
* values, and a `body` key containing the body of the message.
*
* @param string $message HTTP request or response to parse.
*/
public static function parseMessage(string $message): array
{
if (!$message) {
throw new \InvalidArgumentException('Invalid message');
}
$message = ltrim($message, "\r\n");
$messageParts = preg_split("/\r?\n\r?\n/", $message, 2);
if ($messageParts === false || count($messageParts) !== 2) {
throw new \InvalidArgumentException('Invalid message: Missing header delimiter');
}
[$rawHeaders, $body] = $messageParts;
$rawHeaders .= "\r\n"; // Put back the delimiter we split previously
$headerParts = preg_split("/\r?\n/", $rawHeaders, 2);
if ($headerParts === false || count($headerParts) !== 2) {
throw new \InvalidArgumentException('Invalid message: Missing status line');
}
[$startLine, $rawHeaders] = $headerParts;
if (preg_match("/(?:^HTTP\/|^[A-Z]+ \S+ HTTP\/)(\d+(?:\.\d+)?)/i", $startLine, $matches) && $matches[1] === '1.0') {
// Header folding is deprecated for HTTP/1.1, but allowed in HTTP/1.0
$rawHeaders = preg_replace(Rfc7230::HEADER_FOLD_REGEX, ' ', $rawHeaders);
}
/** @var array[] $headerLines */
$count = preg_match_all(Rfc7230::HEADER_REGEX, $rawHeaders, $headerLines, PREG_SET_ORDER);
// If these aren't the same, then one line didn't match and there's an invalid header.
if ($count !== substr_count($rawHeaders, "\n")) {
// Folding is deprecated, see https://tools.ietf.org/html/rfc7230#section-3.2.4
if (preg_match(Rfc7230::HEADER_FOLD_REGEX, $rawHeaders)) {
throw new \InvalidArgumentException('Invalid header syntax: Obsolete line folding');
}
throw new \InvalidArgumentException('Invalid header syntax');
}
$headers = [];
foreach ($headerLines as $headerLine) {
$headers[$headerLine[1]][] = $headerLine[2];
}
return [
'start-line' => $startLine,
'headers' => $headers,
'body' => $body,
];
return MessageParser::parseMessage($message);
}
/**
* Constructs a URI for an HTTP request message.
*
* The URI is composed from the start-line path and the `Host` header, using
* `https` when the host's port is `443` and `http` otherwise. Without a
* `Host` header, only the path is returned, with extra leading slashes
* collapsed so an origin-form target cannot be parsed as a network-path
* reference with its own authority. An `InvalidArgumentException` is thrown
* when the `Host` header is invalid.
*
* @param string $path Path from the start-line
* @param array $headers Array of headers (each value an array).
*/
public static function parseRequestUri(string $path, array $headers): string
{
$hostKey = array_filter(array_keys($headers), function ($k) {
// Numeric array keys are converted to int by PHP.
$k = (string) $k;
return strtolower($k) === 'host';
});
// If no host is found, then a full URI cannot be constructed.
if (!$hostKey) {
return $path;
}
$host = $headers[reset($hostKey)][0];
$scheme = substr($host, -4) === ':443' ? 'https' : 'http';
return $scheme . '://' . $host . '/' . ltrim($path, '/');
return MessageParser::parseRequestUri($path, $headers);
}
/**
* Parses a request message string into a request object.
*
* The request-target must be in origin form, absolute form (without a
* userinfo component), authority form (`CONNECT`), or asterisk form
* (`OPTIONS`), and any `Host` header must be a single valid value;
* otherwise an `InvalidArgumentException` is thrown. Non-origin-form
* targets are preserved on the returned request via `withRequestTarget()`.
*
* @param string $message Request message string.
*/
public static function parseRequest(string $message): RequestInterface
{
$data = self::parseMessage($message);
$matches = [];
if (!preg_match('/^[\S]+\s+([a-zA-Z]+:\/\/|\/).*/', $data['start-line'], $matches)) {
throw new \InvalidArgumentException('Invalid request string');
}
$parts = explode(' ', $data['start-line'], 3);
$version = isset($parts[2]) ? explode('/', $parts[2])[1] : '1.1';
$request = new Request(
$parts[0],
$matches[1] === '/' ? self::parseRequestUri($parts[1], $data['headers']) : $parts[1],
$data['headers'],
$data['body'],
$version
);
return $matches[1] === '/' ? $request : $request->withRequestTarget($parts[1]);
return MessageParser::parseRequest($message);
}
/**
@@ -226,21 +251,6 @@ final class Message
*/
public static function parseResponse(string $message): ResponseInterface
{
$data = self::parseMessage($message);
// According to https://tools.ietf.org/html/rfc7230#section-3.1.2 the space
// between status-code and reason-phrase is required. But browsers accept
// responses without space and reason as well.
if (!preg_match('/^HTTP\/.* [0-9]{3}( .*|$)/', $data['start-line'])) {
throw new \InvalidArgumentException('Invalid response string: ' . $data['start-line']);
}
$parts = explode(' ', $data['start-line'], 3);
return new Response(
(int) $parts[1],
$data['headers'],
$data['body'],
explode('/', $parts[0])[1],
$parts[2] ?? null
);
return MessageParser::parseResponse($message);
}
}
@@ -0,0 +1,363 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* @internal
*/
final class MessageParser
{
private function __construct()
{
}
public static function parseMessage(string $message): array
{
if (!$message) {
throw new \InvalidArgumentException('Invalid message');
}
$message = ltrim($message, "\r\n");
$messageParts = preg_split("/\r?\n\r?\n/", $message, 2);
if ($messageParts === false) {
throw new \RuntimeException('Unable to split HTTP message: '.preg_last_error_msg());
}
if (count($messageParts) !== 2) {
throw new \InvalidArgumentException('Invalid message: Missing header delimiter');
}
[$rawHeaders, $body] = $messageParts;
$rawHeaders .= "\r\n"; // Put back the delimiter we split previously
$headerParts = preg_split("/\r?\n/", $rawHeaders, 2);
if ($headerParts === false) {
throw new \RuntimeException('Unable to split HTTP message headers: '.preg_last_error_msg());
}
if (count($headerParts) !== 2) {
throw new \InvalidArgumentException('Invalid message: Missing status line');
}
[$startLine, $rawHeaders] = $headerParts;
$versionMatch = preg_match(
'/(?:^HTTP\/|^'.Rfc9110::TOKEN_PATTERN.' '.Rfc9112::REQUEST_TARGET_PATTERN.' HTTP\/)('.Rfc9112::PROTOCOL_VERSION_PATTERN.')/i',
$startLine,
$matches
);
if ($versionMatch === false) {
throw new \RuntimeException('Unable to parse HTTP start line: '.preg_last_error_msg());
}
if ($versionMatch === 1 && $matches[1] === '1.0') {
// Header folding is deprecated for HTTP/1.1, but allowed in HTTP/1.0
$rawHeaders = preg_replace(Rfc9112::HEADER_FOLD_REGEX, ' ', $rawHeaders);
if ($rawHeaders === null) {
throw new \RuntimeException('Unable to unfold HTTP headers: '.preg_last_error_msg());
}
}
$count = preg_match_all(Rfc9112::HEADER_REGEX, $rawHeaders, $headerLines, PREG_SET_ORDER);
/** @var list<array<int, string>> $headerLines */
if ($count === false) {
throw new \RuntimeException('Unable to parse HTTP headers: '.preg_last_error_msg());
}
// If these aren't the same, then one line didn't match and there's an invalid header.
if ($count !== substr_count($rawHeaders, "\n")) {
// Folding is deprecated, see https://datatracker.ietf.org/doc/html/rfc9112#section-5.2
$hasFoldedHeader = preg_match(Rfc9112::HEADER_FOLD_REGEX, $rawHeaders);
if ($hasFoldedHeader === false) {
throw new \RuntimeException('Unable to inspect HTTP header folding: '.preg_last_error_msg());
}
if ($hasFoldedHeader === 1) {
throw new \InvalidArgumentException('Invalid header syntax: Obsolete line folding');
}
throw new \InvalidArgumentException('Invalid header syntax');
}
$headers = [];
foreach ($headerLines as $headerLine) {
$headers[$headerLine[1]][] = $headerLine[2];
}
return [
'start-line' => $startLine,
'headers' => $headers,
'body' => $body,
];
}
public static function parseRequestUri(string $path, array $headers): string
{
$host = self::getHostFromHeaders($headers);
// If no host is found, then a full URI cannot be constructed.
// Collapse leading slashes so an origin-form target cannot be
// parsed as a network-path reference with its own authority.
if ($host === null) {
return self::normalizePathForOriginForm($path);
}
[$authorityHost, $port] = self::parseHostHeaderAuthority($host);
$scheme = $port === 443 ? 'https' : 'http';
return $scheme.'://'.self::composeAuthority($authorityHost, $port).'/'.ltrim($path, '/');
}
private static function normalizePathForOriginForm(string $path): string
{
if (str_starts_with($path, '//')) {
return '/'.ltrim($path, '/');
}
return $path;
}
/**
* @return array{0: string, 1: int|null}
*/
private static function parseHostHeaderAuthority(string $authority): array
{
$parsed = Rfc9112::parseHostHeader($authority);
if ($parsed === null) {
throw new \InvalidArgumentException('Invalid request string');
}
return $parsed;
}
private static function composeAuthority(string $host, ?int $port): string
{
return $host.($port !== null ? ':'.$port : '');
}
/**
* @param array $headers Array of headers (each value an array).
*/
private static function getHostFromHeaders(array $headers): ?string
{
$host = self::getSingleHostHeader($headers);
if ($host === null) {
return null;
}
self::parseHostHeaderAuthority($host);
return $host;
}
/**
* @param array $headers Array of headers (each value an array).
*/
private static function getSingleHostHeader(array $headers): ?string
{
$host = null;
$found = false;
foreach ($headers as $name => $values) {
if (Utils::asciiToLower((string) $name) !== 'host') {
continue;
}
if ($found || !is_array($values) || count($values) !== 1) {
throw new \InvalidArgumentException('Invalid request string');
}
$found = true;
$host = reset($values);
}
if (!$found) {
return null;
}
if (!is_string($host)) {
throw new \InvalidArgumentException('Invalid request string');
}
return $host;
}
/**
* @param array $headers Array of headers (each value an array).
*/
private static function parseRequestAuthorityUri(array $headers): string
{
$host = self::getHostFromHeaders($headers);
if ($host === null) {
return '';
}
[$authorityHost, $port] = self::parseHostHeaderAuthority($host);
$scheme = $port === 443 ? 'https' : 'http';
return $scheme.'://'.self::composeAuthority($authorityHost, $port);
}
public static function parseRequest(string $message): RequestInterface
{
$data = self::parseMessage($message);
$matches = [];
$matched = preg_match(
'/^(?P<method>'.Rfc9110::TOKEN_PATTERN.') (?P<target>'.Rfc9112::REQUEST_TARGET_PATTERN.') HTTP\/(?P<version>'.Rfc9112::PROTOCOL_VERSION_PATTERN.')$/D',
$data['start-line'],
$matches
);
if ($matched === false) {
throw new \RuntimeException('Unable to parse request start line: '.preg_last_error_msg());
}
if ($matched === 0) {
throw new \InvalidArgumentException('Invalid request string');
}
self::getHostFromHeaders($data['headers']);
if (str_starts_with($matches['target'], '/')) {
return new Request(
$matches['method'],
self::parseRequestUri($matches['target'], $data['headers']),
$data['headers'],
$data['body'],
$matches['version']
);
}
$absoluteFormUri = self::parseAbsoluteFormRequestTarget($matches['target']);
if ($absoluteFormUri !== null) {
return (new Request(
$matches['method'],
$absoluteFormUri,
$data['headers'],
$data['body'],
$matches['version']
))->withRequestTarget($matches['target']);
}
if (Rfc9112::isAsteriskFormRequestTarget($matches['method'], $matches['target'])) {
return (new Request(
$matches['method'],
self::parseRequestAuthorityUri($data['headers']),
$data['headers'],
$data['body'],
$matches['version']
))->withRequestTarget($matches['target']);
}
$connectUri = self::parseConnectAuthorityFormRequestTarget($matches['method'], $matches['target']);
if ($connectUri !== null) {
return (new Request(
$matches['method'],
$connectUri,
$data['headers'],
$data['body'],
$matches['version']
))->withRequestTarget($matches['target']);
}
throw new \InvalidArgumentException('Invalid request string');
}
private static function parseAbsoluteFormRequestTarget(string $target): ?Uri
{
if (!Rfc9112::isAbsoluteFormRequestTarget($target)) {
return null;
}
$authority = substr($target, strpos($target, '//') + 2);
$authority = substr($authority, 0, strcspn($authority, '/?#'));
// RFC 9110 deprecates userinfo in message target URIs and directs
// recipients to treat its presence as an error, since it can obscure
// the authority. Host headers and CONNECT targets already reject it.
if (str_contains($authority, '@')) {
return null;
}
try {
$uri = new Uri($target);
} catch (\InvalidArgumentException $e) {
return null;
}
if ($uri->getHost() === '') {
return null;
}
try {
self::parseHostHeaderAuthority(self::composeAuthority($uri->getHost(), $uri->getPort()));
} catch (\InvalidArgumentException $e) {
return null;
}
return $uri;
}
private static function parseConnectAuthorityFormRequestTarget(string $method, string $target): ?Uri
{
if (!Rfc9112::isConnectAuthorityFormRequestTarget($method, $target)) {
return null;
}
$parsed = Rfc9112::parseHostHeader($target);
if ($parsed === null) {
return null;
}
[$host, $port] = $parsed;
if ($port === null) {
return null;
}
try {
return new Uri('//'.self::composeAuthority($host, $port));
} catch (\InvalidArgumentException $e) {
return null;
}
}
public static function parseResponse(string $message): ResponseInterface
{
$data = self::parseMessage($message);
// According to https://datatracker.ietf.org/doc/html/rfc9112#section-4
// the space between status-code and reason-phrase is required. But
// browsers accept responses without space and reason as well.
$matched = preg_match(
'/^HTTP\/(?P<version>'.Rfc9112::PROTOCOL_VERSION_PATTERN.') (?P<status>[1-5][0-9]{2})(?: (?P<reason>'.Rfc9110::FIELD_VALUE_PATTERN.'))?$/D',
$data['start-line'],
$matches
);
if ($matched === false) {
throw new \RuntimeException('Unable to parse response start line: '.preg_last_error_msg());
}
if ($matched === 0) {
throw new \InvalidArgumentException(\sprintf('Invalid response string: %s', DiagnosticValue::escape($data['start-line'])));
}
return new Response(
(int) $matches['status'],
$data['headers'],
$data['body'],
$matches['version'],
$matches['reason'] ?? null
);
}
}
+95 -79
View File
@@ -12,31 +12,35 @@ use Psr\Http\Message\StreamInterface;
*/
trait MessageTrait
{
/** @var array<string, string[]> Map of all registered headers, as original name => array of values */
private $headers = [];
/** @var string[][] Map of all registered headers, as original name => array of values */
private array $headers = [];
/** @var array<string, string> Map of lowercase header name => original name at registration */
private $headerNames = [];
/** @var string[] Map of lowercase header name => original name at registration */
private array $headerNames = [];
/** @var string */
private $protocol = '1.1';
private string $protocol = '1.1';
/** @var StreamInterface|null */
private $stream;
private ?StreamInterface $stream = null;
public function getProtocolVersion(): string
{
return $this->protocol;
}
public function withProtocolVersion($version): MessageInterface
/**
* @return static
*/
public function withProtocolVersion(string $version): MessageInterface
{
$this->assertProtocolVersion($version);
if ($this->protocol === $version) {
return $this;
}
$new = clone $this;
$new->protocol = $version;
return $new;
}
@@ -45,14 +49,14 @@ trait MessageTrait
return $this->headers;
}
public function hasHeader($header): bool
public function hasHeader(string $name): bool
{
return isset($this->headerNames[strtolower($header)]);
return isset($this->headerNames[Utils::asciiToLower($name)]);
}
public function getHeader($header): array
public function getHeader(string $name): array
{
$header = strtolower($header);
$header = Utils::asciiToLower($name);
if (!isset($this->headerNames[$header])) {
return [];
@@ -63,57 +67,66 @@ trait MessageTrait
return $this->headers[$header];
}
public function getHeaderLine($header): string
public function getHeaderLine(string $name): string
{
return implode(', ', $this->getHeader($header));
return implode(', ', $this->getHeader($name));
}
public function withHeader($header, $value): MessageInterface
/**
* @return static
*/
public function withHeader(string $name, $value): MessageInterface
{
$this->assertHeader($header);
$value = $this->normalizeHeaderValue($value);
$normalized = strtolower($header);
$this->assertHeader($name);
$value = $this->normalizeHeaderValue($name, $value);
$normalized = Utils::asciiToLower($name);
$new = clone $this;
if (isset($new->headerNames[$normalized])) {
unset($new->headers[$new->headerNames[$normalized]]);
}
$new->headerNames[$normalized] = $header;
$new->headers[$header] = $value;
$new->headerNames[$normalized] = $name;
$new->headers[$name] = $value;
return $new;
}
public function withAddedHeader($header, $value): MessageInterface
/**
* @return static
*/
public function withAddedHeader(string $name, $value): MessageInterface
{
$this->assertHeader($header);
$value = $this->normalizeHeaderValue($value);
$normalized = strtolower($header);
$this->assertHeader($name);
$value = $this->normalizeHeaderValue($name, $value);
$normalized = Utils::asciiToLower($name);
$new = clone $this;
if (isset($new->headerNames[$normalized])) {
$header = $this->headerNames[$normalized];
$new->headers[$header] = array_merge($this->headers[$header], $value);
$name = $this->headerNames[$normalized];
$new->headers[$name] = array_merge($this->headers[$name], $value);
} else {
$new->headerNames[$normalized] = $header;
$new->headers[$header] = $value;
$new->headerNames[$normalized] = $name;
$new->headers[$name] = $value;
}
return $new;
}
public function withoutHeader($header): MessageInterface
/**
* @return static
*/
public function withoutHeader(string $name): MessageInterface
{
$normalized = strtolower($header);
$normalized = Utils::asciiToLower($name);
if (!isset($this->headerNames[$normalized])) {
return $this;
}
$header = $this->headerNames[$normalized];
$name = $this->headerNames[$normalized];
$new = clone $this;
unset($new->headers[$header], $new->headerNames[$normalized]);
unset($new->headers[$name], $new->headerNames[$normalized]);
return $new;
}
@@ -127,6 +140,9 @@ trait MessageTrait
return $this->stream;
}
/**
* @return static
*/
public function withBody(StreamInterface $body): MessageInterface
{
if ($body === $this->stream) {
@@ -135,11 +151,12 @@ trait MessageTrait
$new = clone $this;
$new->stream = $body;
return $new;
}
/**
* @param array<string|int, string|string[]> $headers
* @param (string|string[])[] $headers
*/
private function setHeaders(array $headers): void
{
@@ -149,8 +166,8 @@ trait MessageTrait
$header = (string) $header;
$this->assertHeader($header);
$value = $this->normalizeHeaderValue($value);
$normalized = strtolower($header);
$value = $this->normalizeHeaderValue($header, $value);
$normalized = Utils::asciiToLower($header);
if (isset($this->headerNames[$normalized])) {
$header = $this->headerNames[$normalized];
$this->headers[$header] = array_merge($this->headers[$header], $value);
@@ -166,17 +183,17 @@ trait MessageTrait
*
* @return string[]
*/
private function normalizeHeaderValue($value): array
private function normalizeHeaderValue(string $header, $value): array
{
if (is_array($value) && $value === []) {
throw new \InvalidArgumentException('Header value must be a non-empty array or string.');
}
if (!is_array($value)) {
return $this->trimAndValidateHeaderValues([$value]);
return $this->trimAndValidateHeaderValues($header, [$value]);
}
if (count($value) === 0) {
throw new \InvalidArgumentException('Header value can not be an empty array.');
}
return $this->trimAndValidateHeaderValues($value);
return $this->trimAndValidateHeaderValues($header, $value);
}
/**
@@ -191,48 +208,44 @@ trait MessageTrait
*
* @return string[] Trimmed header values
*
* @see https://tools.ietf.org/html/rfc7230#section-3.2.4
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.5
*/
private function trimAndValidateHeaderValues(array $values): array
private function trimAndValidateHeaderValues(string $header, array $values): array
{
return array_map(function ($value) {
if (!is_scalar($value) && null !== $value) {
return array_map(function ($value) use ($header): string {
if (!is_string($value)) {
throw new \InvalidArgumentException(sprintf(
'Header value must be scalar or null but %s provided.',
is_object($value) ? get_class($value) : gettype($value)
'Header value must be a string or array of strings but %s provided.',
\get_debug_type($value)
));
}
$trimmed = trim((string) $value, " \t");
$this->assertValue($trimmed);
$trimmed = trim($value, " \t");
$this->assertValue($header, $trimmed);
return $trimmed;
}, array_values($values));
}
/**
* @see https://tools.ietf.org/html/rfc7230#section-3.2
*
* @param mixed $header
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.1
*/
private function assertHeader($header): void
private function assertHeader(string $header): void
{
if (!is_string($header)) {
throw new \InvalidArgumentException(sprintf(
'Header name must be a string but %s provided.',
is_object($header) ? get_class($header) : gettype($header)
));
if (!Rfc9110::isToken($header)) {
throw new \InvalidArgumentException(sprintf('Invalid header name: %s', DiagnosticValue::escape($header)));
}
}
if (! preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/D', $header)) {
throw new \InvalidArgumentException(
sprintf('"%s" is not valid header name.', $header)
);
private function assertProtocolVersion(string $version): void
{
if (!Rfc9112::isValidProtocolVersion($version)) {
throw new \InvalidArgumentException('Protocol version must be a valid HTTP version number.');
}
}
/**
* @see https://tools.ietf.org/html/rfc7230#section-3.2
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.5
*
* field-value = *( field-content / obs-fold )
* field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
@@ -241,23 +254,26 @@ trait MessageTrait
* obs-text = %x80-FF
* obs-fold = CRLF 1*( SP / HTAB )
*/
private function assertValue(string $value): void
private function assertValue(string $header, string $value): void
{
// The regular expression intentionally does not support the obs-fold production, because as
// per RFC 7230#3.2.4:
// The regular expression intentionally does not support the obs-fold
// production, because as per RFC 9112#5.2:
//
// A sender MUST NOT generate a message that includes
// line folding (i.e., that has any field-value that contains a match to
// the obs-fold rule) unless the message is intended for packaging
// within the message/http media type.
// A sender MUST NOT generate a message that includes line folding
// (i.e., that has any field-value that contains a match to the obs-fold
// rule) unless the message is intended for packaging within the
// message/http media type.
//
// Clients must not send a request with line folding and a server sending folded headers is
// likely very rare. Line folding is a fairly obscure feature of HTTP/1.1 and thus not accepting
// folding is not likely to break any legitimate use case.
if (! preg_match('/^[\x20\x09\x21-\x7E\x80-\xFF]*$/D', $value)) {
throw new \InvalidArgumentException(
sprintf('"%s" is not valid header value.', $value)
);
// Clients must not send a request with line folding and a server
// sending folded headers is likely very rare. Line folding is a fairly
// obscure feature of HTTP/1.1 and thus not accepting folding is not
// likely to break any legitimate use case.
if (!Rfc9110::isFieldValue($value)) {
$reason = strpbrk($value, "\r\n") !== false
? 'must not contain CR or LF characters'
: 'contains an invalid control character';
throw new \InvalidArgumentException(sprintf('Header "%s" %s.', DiagnosticValue::escape($header), $reason));
}
}
}
+157 -85
View File
@@ -6,50 +6,61 @@ namespace GuzzleHttp\Psr7;
final class MimeType
{
private function __construct()
{
}
private const MIME_TYPES = [
'123' => 'application/vnd.lotus-1-2-3',
'1km' => 'application/vnd.1000minds.decision-model+xml',
'210' => 'model/step',
'3dml' => 'text/vnd.in3d.3dml',
'3ds' => 'image/x-3ds',
'3g2' => 'video/3gpp2',
'3gp' => 'video/3gp',
'3gp' => 'video/3gpp',
'3gpp' => 'video/3gpp',
'3mf' => 'model/3mf',
'7z' => 'application/x-7z-compressed',
'7zip' => 'application/x-7z-compressed',
'123' => 'application/vnd.lotus-1-2-3',
'aab' => 'application/x-authorware-bin',
'aac' => 'audio/x-acc',
'aac' => 'audio/aac',
'aam' => 'application/x-authorware-map',
'aas' => 'application/x-authorware-seg',
'abw' => 'application/x-abiword',
'ac' => 'application/vnd.nokia.n-gage.ac+xml',
'ac' => 'application/pkix-attr-cert',
'ac3' => 'audio/ac3',
'acc' => 'application/vnd.americandynamics.acc',
'ace' => 'application/x-ace-compressed',
'acu' => 'application/vnd.acucobol',
'acutc' => 'application/vnd.acucorp',
'adp' => 'audio/adpcm',
'adts' => 'audio/aac',
'aep' => 'application/vnd.audiograph',
'afm' => 'application/x-font-type1',
'afp' => 'application/vnd.ibm.modcap',
'age' => 'application/vnd.age',
'ahead' => 'application/vnd.ahead.space',
'ai' => 'application/pdf',
'ai' => 'application/postscript',
'aif' => 'audio/x-aiff',
'aifc' => 'audio/x-aiff',
'aiff' => 'audio/x-aiff',
'air' => 'application/vnd.adobe.air-application-installer-package+zip',
'ait' => 'application/vnd.dvb.ait',
'ami' => 'application/vnd.amiga.ami',
'aml' => 'application/automationml-aml+xml',
'amlx' => 'application/automationml-amlx+zip',
'amr' => 'audio/amr',
'apk' => 'application/vnd.android.package-archive',
'apng' => 'image/apng',
'appcache' => 'text/cache-manifest',
'appinstaller' => 'application/appinstaller',
'application' => 'application/x-ms-application',
'appx' => 'application/appx',
'appxbundle' => 'application/appxbundle',
'apr' => 'application/vnd.lotus-approach',
'arc' => 'application/x-freearc',
'arj' => 'application/x-arj',
'asc' => 'application/pgp-signature',
'asc' => 'application/pgp-keys',
'asf' => 'video/x-ms-asf',
'asm' => 'text/x-asm',
'aso' => 'application/vnd.accpac.simply.aso',
@@ -60,7 +71,7 @@ final class MimeType
'atomdeleted' => 'application/atomdeleted+xml',
'atomsvc' => 'application/atomsvc+xml',
'atx' => 'application/vnd.antix.game-component',
'au' => 'audio/x-au',
'au' => 'audio/basic',
'avci' => 'image/avci',
'avcs' => 'image/avcs',
'avi' => 'video/x-msvideo',
@@ -71,15 +82,18 @@ final class MimeType
'azv' => 'image/vnd.airzip.accelerator.azv',
'azw' => 'application/vnd.amazon.ebook',
'b16' => 'image/vnd.pco.b16',
'bary' => 'model/vnd.bary',
'bat' => 'application/x-msdownload',
'bcpio' => 'application/x-bcpio',
'bdf' => 'application/x-font-bdf',
'bdm' => 'application/vnd.syncml.dm+wbxml',
'bdoc' => 'application/x-bdoc',
'bdo' => 'application/vnd.nato.bindingdataobject+xml',
'bdoc' => 'application/bdoc',
'bed' => 'application/vnd.realvnc.bed',
'bh2' => 'application/vnd.fujitsu.oasysprs',
'bin' => 'application/octet-stream',
'blb' => 'application/x-blorb',
'blend' => 'application/x-blender',
'blorb' => 'application/x-blorb',
'bmi' => 'application/vnd.bmi',
'bmml' => 'application/vnd.balsamiq.bmml+xml',
@@ -89,19 +103,22 @@ final class MimeType
'boz' => 'application/x-bzip2',
'bpk' => 'application/octet-stream',
'bpmn' => 'application/octet-stream',
'brush' => 'application/vnd.procreate.brush',
'brushset' => 'application/vnd.procreate.brushset',
'bsp' => 'model/vnd.valve.source.compiled-map',
'btf' => 'image/prs.btif',
'btif' => 'image/prs.btif',
'buffer' => 'application/octet-stream',
'bz' => 'application/x-bzip',
'bz2' => 'application/x-bzip2',
'c' => 'text/x-c',
'c11amc' => 'application/vnd.cluetrust.cartomobile-config',
'c11amz' => 'application/vnd.cluetrust.cartomobile-config-pkg',
'c4d' => 'application/vnd.clonk.c4group',
'c4f' => 'application/vnd.clonk.c4group',
'c4g' => 'application/vnd.clonk.c4group',
'c4p' => 'application/vnd.clonk.c4group',
'c4u' => 'application/vnd.clonk.c4group',
'c11amc' => 'application/vnd.cluetrust.cartomobile-config',
'c11amz' => 'application/vnd.cluetrust.cartomobile-config-pkg',
'cab' => 'application/vnd.ms-cab-compressed',
'caf' => 'audio/x-caf',
'cap' => 'application/vnd.tcpdump.pcap',
@@ -125,7 +142,6 @@ final class MimeType
'cdmid' => 'application/cdmi-domain',
'cdmio' => 'application/cdmi-object',
'cdmiq' => 'application/cdmi-queue',
'cdr' => 'application/cdr',
'cdx' => 'chemical/x-cdx',
'cdxml' => 'application/vnd.chemdraw+xml',
'cdy' => 'application/vnd.cinderella',
@@ -140,7 +156,8 @@ final class MimeType
'cil' => 'application/vnd.ms-artgalry',
'cjs' => 'application/node',
'cla' => 'application/vnd.claymore',
'class' => 'application/octet-stream',
'class' => 'application/java-vm',
'cld' => 'model/vnd.cld',
'clkk' => 'application/vnd.crick.clicker.keyboard',
'clkp' => 'application/vnd.crick.clicker.palette',
'clkt' => 'application/vnd.crick.clicker.template',
@@ -175,6 +192,7 @@ final class MimeType
'csv' => 'text/csv',
'cu' => 'application/cu-seeme',
'curl' => 'text/vnd.curl',
'cwl' => 'application/cwl',
'cww' => 'application/prs.cww',
'cxt' => 'application/x-director',
'cxx' => 'text/x-c',
@@ -185,6 +203,8 @@ final class MimeType
'davmount' => 'application/davmount+xml',
'dbf' => 'application/vnd.dbf',
'dbk' => 'application/docbook+xml',
'dcm' => 'application/dicom',
'dcmp' => 'application/vnd.dcmp+xml',
'dcr' => 'application/x-director',
'dcurl' => 'text/vnd.curl.dcurl',
'dd2' => 'application/vnd.oma.dd2+xml',
@@ -197,6 +217,7 @@ final class MimeType
'der' => 'application/x-x509-ca-cert',
'dfac' => 'application/vnd.dreamfactory',
'dgc' => 'application/x-dgc-compressed',
'dib' => 'image/bmp',
'dic' => 'text/x-c',
'dir' => 'application/x-director',
'dis' => 'application/vnd.mobius.dis',
@@ -211,18 +232,22 @@ final class MimeType
'dmp' => 'application/vnd.tcpdump.pcap',
'dms' => 'application/octet-stream',
'dna' => 'application/vnd.dna',
'dng' => 'image/x-adobe-dng',
'doc' => 'application/msword',
'docm' => 'application/vnd.ms-word.template.macroEnabled.12',
'docm' => 'application/vnd.ms-word.document.macroenabled.12',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'dot' => 'application/msword',
'dotm' => 'application/vnd.ms-word.template.macroEnabled.12',
'dotm' => 'application/vnd.ms-word.template.macroenabled.12',
'dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
'dp' => 'application/vnd.osgi.dp',
'dpg' => 'application/vnd.dpgraph',
'dpx' => 'image/dpx',
'dra' => 'audio/vnd.dra',
'drle' => 'image/dicom-rle',
'drm' => 'application/vnd.procreate.dream',
'dsc' => 'text/prs.lines.tag',
'dssc' => 'application/dssc+der',
'dst' => 'application/octet-stream',
'dtb' => 'application/x-dtbook+xml',
'dtd' => 'application/xml-dtd',
'dts' => 'audio/vnd.dts',
@@ -255,7 +280,6 @@ final class MimeType
'eot' => 'application/vnd.ms-fontobject',
'eps' => 'application/postscript',
'epub' => 'application/epub+zip',
'es' => 'application/ecmascript',
'es3' => 'application/vnd.eszigno3+xml',
'esa' => 'application/vnd.osgi.subsystem',
'esf' => 'application/vnd.epson.esf',
@@ -275,10 +299,12 @@ final class MimeType
'f4v' => 'video/mp4',
'f77' => 'text/x-fortran',
'f90' => 'text/x-fortran',
'facti' => 'image/vnd.blockfact.facti',
'fbs' => 'image/vnd.fastbidsheet',
'fbx' => 'application/vnd.autodesk.fbx',
'fcdt' => 'application/vnd.adobe.formscentral.fcdt',
'fcs' => 'application/vnd.isac.fcs',
'fdf' => 'application/vnd.fdf',
'fdf' => 'application/fdf',
'fdt' => 'application/fdt+xml',
'fe_launch' => 'application/vnd.denovo.fcselayout-link',
'fg5' => 'application/vnd.fujitsu.oasysgp',
@@ -320,21 +346,25 @@ final class MimeType
'gca' => 'application/x-gca-compressed',
'gdl' => 'model/vnd.gdl',
'gdoc' => 'application/vnd.google-apps.document',
'gdraw' => 'application/vnd.google-apps.drawing',
'ged' => 'text/vnd.familysearch.gedcom',
'geo' => 'application/vnd.dynageo',
'geojson' => 'application/geo+json',
'gex' => 'application/vnd.geometry-explorer',
'gform' => 'application/vnd.google-apps.form',
'ggb' => 'application/vnd.geogebra.file',
'ggs' => 'application/vnd.geogebra.slides',
'ggt' => 'application/vnd.geogebra.tool',
'ghf' => 'application/vnd.groove-help',
'gif' => 'image/gif',
'gim' => 'application/vnd.groove-identity-message',
'gjam' => 'application/vnd.google-apps.jam',
'glb' => 'model/gltf-binary',
'gltf' => 'model/gltf+json',
'gmap' => 'application/vnd.google-apps.map',
'gml' => 'application/gml+xml',
'gmx' => 'application/vnd.gmx',
'gnumeric' => 'application/x-gnumeric',
'gpg' => 'application/gpg-keys',
'gph' => 'application/vnd.flographit',
'gpx' => 'application/gpx+xml',
'gqf' => 'application/vnd.grafeq',
@@ -344,8 +374,10 @@ final class MimeType
'gre' => 'application/vnd.geometry-explorer',
'grv' => 'application/vnd.groove-injector',
'grxml' => 'application/srgs+xml',
'gscript' => 'application/vnd.google-apps.script',
'gsf' => 'application/x-font-ghostscript',
'gsheet' => 'application/vnd.google-apps.spreadsheet',
'gsite' => 'application/vnd.google-apps.site',
'gslides' => 'application/vnd.google-apps.presentation',
'gtar' => 'application/x-gtar',
'gtm' => 'application/vnd.groove-tool-message',
@@ -377,7 +409,6 @@ final class MimeType
'hpid' => 'application/vnd.hp-hpid',
'hps' => 'application/vnd.hp-hps',
'hqx' => 'application/mac-binhex40',
'hsj2' => 'image/hsj2',
'htc' => 'text/x-component',
'htke' => 'application/vnd.kenameaapp',
'htm' => 'text/html',
@@ -389,7 +420,7 @@ final class MimeType
'icc' => 'application/vnd.iccprofile',
'ice' => 'x-conference/x-cooltalk',
'icm' => 'application/vnd.iccprofile',
'ico' => 'image/x-icon',
'ico' => 'image/vnd.microsoft.icon',
'ics' => 'text/calendar',
'ief' => 'image/ief',
'ifb' => 'text/calendar',
@@ -404,6 +435,7 @@ final class MimeType
'imp' => 'application/vnd.accpac.simply.imp',
'ims' => 'application/vnd.ms-ims',
'in' => 'text/plain',
'indd' => 'application/x-indesign',
'ini' => 'text/plain',
'ink' => 'application/inkml+xml',
'inkml' => 'application/inkml+xml',
@@ -411,6 +443,7 @@ final class MimeType
'iota' => 'application/vnd.astraea-software.iota',
'ipfix' => 'application/ipfix',
'ipk' => 'application/vnd.shana.informed.package',
'ipynb' => 'application/x-ipynb+json',
'irm' => 'application/vnd.ibm.rights-management',
'irp' => 'application/vnd.irepository.package+xml',
'iso' => 'application/x-iso9660-image',
@@ -420,10 +453,13 @@ final class MimeType
'ivu' => 'application/vnd.immervision-ivu',
'jad' => 'text/vnd.sun.j2me.app-descriptor',
'jade' => 'text/jade',
'jaii' => 'image/jaii',
'jais' => 'image/jais',
'jam' => 'application/vnd.jam',
'jar' => 'application/java-archive',
'jardiff' => 'application/x-java-archive-diff',
'java' => 'text/x-java-source',
'jfif' => 'image/jpeg',
'jhc' => 'image/jphc',
'jisp' => 'application/vnd.jisp',
'jls' => 'image/jls',
@@ -437,17 +473,19 @@ final class MimeType
'jpf' => 'image/jpx',
'jpg' => 'image/jpeg',
'jpg2' => 'image/jp2',
'jpgm' => 'video/jpm',
'jpgm' => 'image/jpm',
'jpgv' => 'video/jpeg',
'jph' => 'image/jph',
'jpm' => 'video/jpm',
'jpm' => 'image/jpm',
'jpx' => 'image/jpx',
'js' => 'application/javascript',
'js' => 'text/javascript',
'json' => 'application/json',
'json5' => 'application/json5',
'jsonld' => 'application/ld+json',
'jsonml' => 'application/jsonml+json',
'jsx' => 'text/jsx',
'jt' => 'model/jt',
'jxl' => 'image/jxl',
'jxr' => 'image/jxr',
'jxra' => 'image/jxra',
'jxrs' => 'image/jxrs',
@@ -457,9 +495,10 @@ final class MimeType
'jxss' => 'image/jxss',
'kar' => 'audio/midi',
'karbon' => 'application/vnd.kde.karbon',
'kbl' => 'application/kbl+xml',
'kdb' => 'application/octet-stream',
'kdbx' => 'application/x-keepass2',
'key' => 'application/x-iwork-keynote-sffkey',
'key' => 'application/vnd.apple.keynote',
'kfo' => 'application/vnd.kde.kformula',
'kia' => 'application/vnd.kidspiration',
'kml' => 'application/vnd.google-earth.kml+xml',
@@ -484,7 +523,7 @@ final class MimeType
'les' => 'application/vnd.hhe.lesson-player',
'less' => 'text/less',
'lgr' => 'application/lgr+xml',
'lha' => 'application/octet-stream',
'lha' => 'application/x-lzh-compressed',
'link66' => 'application/vnd.route66.link66+xml',
'list' => 'text/plain',
'list3820' => 'application/vnd.ibm.modcap',
@@ -493,6 +532,7 @@ final class MimeType
'lnk' => 'application/x-ms-shortcut',
'log' => 'text/plain',
'lostxml' => 'application/lost+xml',
'lottie' => 'application/zip+dotlottie',
'lrf' => 'application/octet-stream',
'lrm' => 'application/vnd.ms-lrm',
'ltf' => 'application/vnd.frogans.ltf',
@@ -500,21 +540,24 @@ final class MimeType
'luac' => 'application/x-lua-bytecode',
'lvp' => 'audio/vnd.lucent.voice',
'lwp' => 'application/vnd.lotus-wordpro',
'lzh' => 'application/octet-stream',
'm1v' => 'video/mpeg',
'm2a' => 'audio/mpeg',
'm2v' => 'video/mpeg',
'm3a' => 'audio/mpeg',
'm3u' => 'text/plain',
'm3u8' => 'application/vnd.apple.mpegurl',
'm4a' => 'audio/x-m4a',
'm4p' => 'application/mp4',
'm4s' => 'video/iso.segment',
'm4u' => 'application/vnd.mpegurl',
'm4v' => 'video/x-m4v',
'lzh' => 'application/x-lzh-compressed',
'm13' => 'application/x-msmediaview',
'm14' => 'application/x-msmediaview',
'm1v' => 'video/mpeg',
'm21' => 'application/mp21',
'm2a' => 'audio/mpeg',
'm2t' => 'video/mp2t',
'm2ts' => 'video/mp2t',
'm2v' => 'video/mpeg',
'm3a' => 'audio/mpeg',
'm3u' => 'audio/x-mpegurl',
'm3u8' => 'application/vnd.apple.mpegurl',
'm4a' => 'audio/mp4',
'm4b' => 'audio/mp4',
'm4p' => 'application/mp4',
'm4s' => 'video/iso.segment',
'm4u' => 'video/vnd.mpegurl',
'm4v' => 'video/x-m4v',
'ma' => 'application/mathematica',
'mads' => 'application/mads+xml',
'maei' => 'application/mmt-aei+xml',
@@ -545,6 +588,8 @@ final class MimeType
'mft' => 'application/rpki-manifest',
'mgp' => 'application/vnd.osgeo.mapguide.package',
'mgz' => 'application/vnd.proteus.magazine',
'mht' => 'message/rfc822',
'mhtml' => 'message/rfc822',
'mid' => 'audio/midi',
'midi' => 'audio/midi',
'mie' => 'application/x-mie',
@@ -552,12 +597,12 @@ final class MimeType
'mime' => 'message/rfc822',
'mj2' => 'video/mj2',
'mjp2' => 'video/mj2',
'mjs' => 'application/javascript',
'mk3d' => 'video/x-matroska',
'mka' => 'audio/x-matroska',
'mjs' => 'text/javascript',
'mk3d' => 'video/matroska-3d',
'mka' => 'audio/matroska',
'mkd' => 'text/x-markdown',
'mks' => 'video/x-matroska',
'mkv' => 'video/x-matroska',
'mkv' => 'video/matroska',
'mlp' => 'application/vnd.dolby.mlp',
'mmd' => 'application/vnd.chipnuts.karaoke-mmd',
'mmf' => 'application/vnd.smaf',
@@ -570,13 +615,13 @@ final class MimeType
'mov' => 'video/quicktime',
'movie' => 'video/x-sgi-movie',
'mp2' => 'audio/mpeg',
'mp21' => 'application/mp21',
'mp2a' => 'audio/mpeg',
'mp3' => 'audio/mpeg',
'mp4' => 'video/mp4',
'mp4a' => 'audio/mp4',
'mp4s' => 'application/mp4',
'mp4v' => 'video/mp4',
'mp21' => 'application/mp21',
'mpc' => 'application/vnd.mophun.certificate',
'mpd' => 'application/dash+xml',
'mpe' => 'video/mpeg',
@@ -601,13 +646,15 @@ final class MimeType
'msf' => 'application/vnd.epson.msf',
'msg' => 'application/vnd.ms-outlook',
'msh' => 'model/mesh',
'msi' => 'application/x-msdownload',
'msi' => 'application/octet-stream',
'msix' => 'application/msix',
'msixbundle' => 'application/msixbundle',
'msl' => 'application/vnd.mobius.msl',
'msm' => 'application/octet-stream',
'msp' => 'application/octet-stream',
'msty' => 'application/vnd.muvee.style',
'mtl' => 'model/mtl',
'mts' => 'model/vnd.mts',
'mts' => 'video/mp2t',
'mus' => 'application/vnd.musician',
'musd' => 'application/mmt-usd+xml',
'musicxml' => 'application/vnd.recordare.musicxml+xml',
@@ -626,6 +673,7 @@ final class MimeType
'nbp' => 'application/vnd.wolfram.player',
'nc' => 'application/x-netcdf',
'ncx' => 'application/x-dtbncx+xml',
'ndjson' => 'application/x-ndjson',
'nfo' => 'text/x-nfo',
'ngdat' => 'application/vnd.nokia.n-gage.data',
'nitf' => 'application/vnd.nitf',
@@ -640,7 +688,7 @@ final class MimeType
'nsf' => 'application/vnd.lotus-notes',
'nt' => 'application/n-triples',
'ntf' => 'application/vnd.nitf',
'numbers' => 'application/x-iwork-numbers-sffnumbers',
'numbers' => 'application/vnd.apple.numbers',
'nzb' => 'application/x-nzb',
'oa2' => 'application/vnd.fujitsu.oasys2',
'oa3' => 'application/vnd.fujitsu.oasys3',
@@ -665,6 +713,8 @@ final class MimeType
'ogv' => 'video/ogg',
'ogx' => 'application/ogg',
'omdoc' => 'application/omdoc+xml',
'one' => 'application/onenote',
'onea' => 'application/onenote',
'onepkg' => 'application/onenote',
'onetmp' => 'application/onenote',
'onetoc' => 'application/onenote',
@@ -673,7 +723,7 @@ final class MimeType
'opml' => 'text/x-opml',
'oprc' => 'application/vnd.palm',
'opus' => 'audio/ogg',
'org' => 'text/x-org',
'org' => 'application/vnd.lotus-organizer',
'osf' => 'application/vnd.yamaha.openscoreformat',
'osfpvg' => 'application/vnd.yamaha.openscoreformat.osfpvg+xml',
'osm' => 'application/vnd.openstreetmap.data+xml',
@@ -691,17 +741,20 @@ final class MimeType
'oxps' => 'application/oxps',
'oxt' => 'application/vnd.openofficeorg.extension',
'p' => 'text/x-pascal',
'p10' => 'application/pkcs10',
'p12' => 'application/x-pkcs12',
'p21' => 'model/step',
'p7a' => 'application/x-pkcs7-signature',
'p7b' => 'application/x-pkcs7-certificates',
'p7c' => 'application/pkcs7-mime',
'p7e' => 'application/pkcs7-mime',
'p7m' => 'application/pkcs7-mime',
'p7r' => 'application/x-pkcs7-certreqresp',
'p7s' => 'application/pkcs7-signature',
'p8' => 'application/pkcs8',
'p10' => 'application/x-pkcs10',
'p12' => 'application/x-pkcs12',
'pac' => 'application/x-ns-proxy-autoconfig',
'pages' => 'application/x-iwork-pages-sffpages',
'pages' => 'application/vnd.apple.pages',
'parquet' => 'application/vnd.apache.parquet',
'pas' => 'text/x-pascal',
'paw' => 'application/vnd.pawaafile',
'pbd' => 'application/vnd.powerbuilder6',
@@ -712,8 +765,8 @@ final class MimeType
'pclxl' => 'application/vnd.hp-pclxl',
'pct' => 'image/x-pict',
'pcurl' => 'application/vnd.curl.pcurl',
'pcx' => 'image/x-pcx',
'pdb' => 'application/x-pilot',
'pcx' => 'image/vnd.zbrush.pcx',
'pdb' => 'application/vnd.palm',
'pde' => 'text/x-processing',
'pdf' => 'application/pdf',
'pem' => 'application/x-x509-user-cert',
@@ -724,7 +777,7 @@ final class MimeType
'pfx' => 'application/x-pkcs12',
'pgm' => 'image/x-portable-graymap',
'pgn' => 'application/x-chess-pgn',
'pgp' => 'application/pgp',
'pgp' => 'application/pgp-encrypted',
'phar' => 'application/octet-stream',
'php' => 'application/x-httpd-php',
'php3' => 'application/x-httpd-php',
@@ -747,17 +800,17 @@ final class MimeType
'pnm' => 'image/x-portable-anymap',
'portpkg' => 'application/vnd.macports.portpkg',
'pot' => 'application/vnd.ms-powerpoint',
'potm' => 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
'potm' => 'application/vnd.ms-powerpoint.template.macroenabled.12',
'potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template',
'ppa' => 'application/vnd.ms-powerpoint',
'ppam' => 'application/vnd.ms-powerpoint.addin.macroEnabled.12',
'ppam' => 'application/vnd.ms-powerpoint.addin.macroenabled.12',
'ppd' => 'application/vnd.cups-ppd',
'ppm' => 'image/x-portable-pixmap',
'pps' => 'application/vnd.ms-powerpoint',
'ppsm' => 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12',
'ppsm' => 'application/vnd.ms-powerpoint.slideshow.macroenabled.12',
'ppsx' => 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
'ppt' => 'application/powerpoint',
'pptm' => 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
'ppt' => 'application/vnd.ms-powerpoint',
'pptm' => 'application/vnd.ms-powerpoint.presentation.macroenabled.12',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'pqa' => 'application/vnd.palm',
'prc' => 'model/prc',
@@ -766,15 +819,19 @@ final class MimeType
'provx' => 'application/provenance+xml',
'ps' => 'application/postscript',
'psb' => 'application/vnd.3gpp.pic-bw-small',
'psd' => 'application/x-photoshop',
'psd' => 'image/vnd.adobe.photoshop',
'psf' => 'application/x-font-linux-psf',
'pskcxml' => 'application/pskc+xml',
'pti' => 'image/prs.pti',
'ptid' => 'application/vnd.pvi.ptid1',
'pub' => 'application/x-mspublisher',
'pv' => 'application/octet-stream',
'pvb' => 'application/vnd.3gpp.pic-bw-var',
'pwn' => 'application/vnd.3m.post-it-notes',
'pxf' => 'application/octet-stream',
'pya' => 'audio/vnd.ms-playready.media.pya',
'pyo' => 'model/vnd.pytha.pyox',
'pyox' => 'model/vnd.pytha.pyox',
'pyv' => 'video/vnd.ms-playready.media.pyv',
'qam' => 'application/vnd.epson.quickanime',
'qbo' => 'application/vnd.intu.qbo',
@@ -791,7 +848,7 @@ final class MimeType
'ram' => 'audio/x-pn-realaudio',
'raml' => 'application/raml+yaml',
'rapd' => 'application/route-apd+xml',
'rar' => 'application/x-rar',
'rar' => 'application/vnd.rar',
'ras' => 'image/x-cmu-raster',
'rcprofile' => 'application/vnd.ipunplugged.rcprofile',
'rdf' => 'application/rdf+xml',
@@ -806,7 +863,7 @@ final class MimeType
'rl' => 'application/resource-lists+xml',
'rlc' => 'image/vnd.fujixerox.edmics-rlc',
'rld' => 'application/resource-lists-diff+xml',
'rm' => 'audio/x-pn-realaudio',
'rm' => 'application/vnd.rn-realmedia',
'rmi' => 'audio/midi',
'rmp' => 'audio/x-pn-realaudio-plugin',
'rms' => 'application/vnd.jcp.javame.midlet-rms',
@@ -816,7 +873,7 @@ final class MimeType
'roa' => 'application/rpki-roa',
'roff' => 'text/troff',
'rp9' => 'application/vnd.cloanto.rp9',
'rpm' => 'audio/x-pn-realaudio-plugin',
'rpm' => 'application/x-redhat-package-manager',
'rpss' => 'application/vnd.nokia.radio-presets',
'rpst' => 'application/vnd.nokia.radio-preset',
'rq' => 'application/sparql-query',
@@ -850,7 +907,7 @@ final class MimeType
'sdkm' => 'application/vnd.solent.sdkm+xml',
'sdp' => 'application/sdp',
'sdw' => 'application/vnd.stardivision.writer',
'sea' => 'application/octet-stream',
'sea' => 'application/x-sea',
'see' => 'application/vnd.seemail',
'seed' => 'application/vnd.fdsn.seed',
'sema' => 'application/vnd.sema',
@@ -895,8 +952,8 @@ final class MimeType
'slt' => 'application/vnd.epson.salt',
'sm' => 'application/vnd.stepmania.stepchart',
'smf' => 'application/vnd.stardivision.math',
'smi' => 'application/smil',
'smil' => 'application/smil',
'smi' => 'application/smil+xml',
'smil' => 'application/smil+xml',
'smv' => 'video/x-smv',
'smzip' => 'application/vnd.stepmania.package',
'snd' => 'audio/basic',
@@ -910,7 +967,9 @@ final class MimeType
'spp' => 'application/scvp-vp-response',
'spq' => 'application/scvp-vp-request',
'spx' => 'audio/ogg',
'sql' => 'application/x-sql',
'sql' => 'application/sql',
'sqlite' => 'application/vnd.sqlite3',
'sqlite3' => 'application/vnd.sqlite3',
'src' => 'application/x-wais-source',
'srt' => 'application/x-subrip',
'sru' => 'application/sru+xml',
@@ -923,10 +982,13 @@ final class MimeType
'st' => 'application/vnd.sailingtracker.track',
'stc' => 'application/vnd.sun.xml.calc.template',
'std' => 'application/vnd.sun.xml.draw.template',
'step' => 'model/step',
'stf' => 'application/vnd.wt.stf',
'sti' => 'application/vnd.sun.xml.impress.template',
'stk' => 'application/hyperstudio',
'stl' => 'model/stl',
'stp' => 'model/step',
'stpnc' => 'model/step',
'stpx' => 'model/step+xml',
'stpxz' => 'model/step-xml+zip',
'stpz' => 'model/step+zip',
@@ -934,7 +996,7 @@ final class MimeType
'stw' => 'application/vnd.sun.xml.writer.template',
'styl' => 'text/stylus',
'stylus' => 'text/stylus',
'sub' => 'text/vnd.dvb.subtitle',
'sub' => 'image/vnd.dvb.subtitle',
'sus' => 'application/vnd.sus-calendar',
'susp' => 'application/vnd.sus-calendar',
'sv4cpio' => 'application/x-sv4cpio',
@@ -953,6 +1015,7 @@ final class MimeType
'sxi' => 'application/vnd.sun.xml.impress',
'sxm' => 'application/vnd.sun.xml.math',
'sxw' => 'application/vnd.sun.xml.writer',
'systemverify' => 'application/vnd.pp.systemverify+xml',
't' => 'text/troff',
't3' => 'application/x-t3vm-image',
't38' => 'image/t38',
@@ -974,7 +1037,7 @@ final class MimeType
'tfm' => 'application/x-tex-tfm',
'tfx' => 'image/tiff-fx',
'tga' => 'image/x-tga',
'tgz' => 'application/x-tar',
'tgz' => 'application/gzip',
'thmx' => 'application/vnd.ms-officetheme',
'tif' => 'image/tiff',
'tiff' => 'image/tiff',
@@ -1000,12 +1063,12 @@ final class MimeType
'txd' => 'application/vnd.genomatix.tuxedo',
'txf' => 'application/vnd.mobius.txf',
'txt' => 'text/plain',
'u32' => 'application/x-authorware-bin',
'u3d' => 'model/u3d',
'u8dsn' => 'message/global-delivery-status',
'u8hdr' => 'message/global-headers',
'u8mdn' => 'message/global-disposition-notification',
'u8msg' => 'message/global',
'u32' => 'application/x-authorware-bin',
'ubj' => 'application/ubjson',
'udeb' => 'application/x-debian-package',
'ufd' => 'application/vnd.ufdl',
@@ -1013,10 +1076,12 @@ final class MimeType
'ulx' => 'application/x-glulx',
'umj' => 'application/vnd.umajin',
'unityweb' => 'application/vnd.unity',
'uo' => 'application/vnd.uoml+xml',
'uoml' => 'application/vnd.uoml+xml',
'uri' => 'text/uri-list',
'uris' => 'text/uri-list',
'urls' => 'text/uri-list',
'usda' => 'model/vnd.usda',
'usdz' => 'model/vnd.usdz+zip',
'ustar' => 'application/x-ustar',
'utz' => 'application/vnd.uiq.theme',
@@ -1059,16 +1124,18 @@ final class MimeType
'vcx' => 'application/vnd.vcx',
'vdi' => 'application/x-virtualbox-vdi',
'vds' => 'model/vnd.sap.vds',
'vdx' => 'application/vnd.ms-visio.viewer',
'vec' => 'application/vec+xml',
'vhd' => 'application/x-virtualbox-vhd',
'vis' => 'application/vnd.visionary',
'viv' => 'video/vnd.vivo',
'vlc' => 'application/videolan',
'vmdk' => 'application/x-virtualbox-vmdk',
'vob' => 'video/x-ms-vob',
'vor' => 'application/vnd.stardivision.writer',
'vox' => 'application/x-authorware-bin',
'vrml' => 'model/vrml',
'vsd' => 'application/vnd.visio',
'vsdx' => 'application/vnd.visio',
'vsf' => 'application/vnd.vsf',
'vss' => 'application/vnd.visio',
'vst' => 'application/vnd.visio',
@@ -1076,17 +1143,18 @@ final class MimeType
'vtf' => 'image/vnd.valve.source.texture',
'vtt' => 'text/vtt',
'vtu' => 'model/vnd.vtu',
'vtx' => 'application/vnd.visio',
'vxml' => 'application/voicexml+xml',
'w3d' => 'application/x-director',
'wad' => 'application/x-doom',
'wadl' => 'application/vnd.sun.wadl+xml',
'war' => 'application/java-archive',
'wasm' => 'application/wasm',
'wav' => 'audio/x-wav',
'wav' => 'audio/wav',
'wax' => 'audio/x-ms-wax',
'wbmp' => 'image/vnd.wap.wbmp',
'wbs' => 'application/vnd.criticaltools.wbs+xml',
'wbxml' => 'application/wbxml',
'wbxml' => 'application/vnd.wap.wbxml',
'wcm' => 'application/vnd.ms-works',
'wdb' => 'application/vnd.ms-works',
'wdp' => 'image/vnd.ms-photo',
@@ -1096,6 +1164,7 @@ final class MimeType
'webmanifest' => 'application/manifest+json',
'webp' => 'image/webp',
'wg' => 'application/vnd.pmi.widget',
'wgsl' => 'text/wgsl',
'wgt' => 'application/widget',
'wif' => 'application/watcherinfo+xml',
'wks' => 'application/vnd.ms-works',
@@ -1104,12 +1173,12 @@ final class MimeType
'wmd' => 'application/x-ms-wmd',
'wmf' => 'image/wmf',
'wml' => 'text/vnd.wap.wml',
'wmlc' => 'application/wmlc',
'wmlc' => 'application/vnd.wap.wmlc',
'wmls' => 'text/vnd.wap.wmlscript',
'wmlsc' => 'application/vnd.wap.wmlscriptc',
'wmv' => 'video/x-ms-wmv',
'wmx' => 'video/x-ms-wmx',
'wmz' => 'application/x-msmetafile',
'wmz' => 'application/x-ms-wmz',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'word' => 'application/msword',
@@ -1124,13 +1193,13 @@ final class MimeType
'wspolicy' => 'application/wspolicy+xml',
'wtb' => 'application/vnd.webturbo',
'wvx' => 'video/x-ms-wvx',
'x32' => 'application/x-authorware-bin',
'x3d' => 'model/x3d+xml',
'x3db' => 'model/x3d+fastinfoset',
'x3dbz' => 'model/x3d+binary',
'x3dv' => 'model/x3d-vrml',
'x3dvz' => 'model/x3d+vrml',
'x3dz' => 'model/x3d+xml',
'x32' => 'application/x-authorware-bin',
'x_b' => 'model/vnd.parasolid.transmit.binary',
'x_t' => 'model/vnd.parasolid.transmit.text',
'xaml' => 'application/xaml+xml',
@@ -1142,6 +1211,7 @@ final class MimeType
'xbm' => 'image/x-xbitmap',
'xca' => 'application/xcap-caps+xml',
'xcs' => 'application/calendar+xml',
'xdcf' => 'application/vnd.gov.sk.xmldatacontainer+xml',
'xdf' => 'application/xcap-diff+xml',
'xdm' => 'application/vnd.syncml.dm+xml',
'xdp' => 'application/vnd.adobe.xdp+xml',
@@ -1150,24 +1220,25 @@ final class MimeType
'xel' => 'application/xcap-el+xml',
'xenc' => 'application/xenc+xml',
'xer' => 'application/patch-ops-error+xml',
'xfdf' => 'application/vnd.adobe.xfdf',
'xfdf' => 'application/xfdf',
'xfdl' => 'application/vnd.xfdl',
'xht' => 'application/xhtml+xml',
'xhtm' => 'application/vnd.pwg-xhtml-print+xml',
'xhtml' => 'application/xhtml+xml',
'xhvml' => 'application/xv+xml',
'xif' => 'image/vnd.xiff',
'xl' => 'application/excel',
'xl' => 'application/vnd.ms-excel',
'xla' => 'application/vnd.ms-excel',
'xlam' => 'application/vnd.ms-excel.addin.macroEnabled.12',
'xlam' => 'application/vnd.ms-excel.addin.macroenabled.12',
'xlc' => 'application/vnd.ms-excel',
'xlf' => 'application/xliff+xml',
'xlm' => 'application/vnd.ms-excel',
'xls' => 'application/vnd.ms-excel',
'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',
'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroenabled.12',
'xlsm' => 'application/vnd.ms-excel.sheet.macroenabled.12',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xlt' => 'application/vnd.ms-excel',
'xltm' => 'application/vnd.ms-excel.template.macroEnabled.12',
'xltm' => 'application/vnd.ms-excel.template.macroenabled.12',
'xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
'xlw' => 'application/vnd.ms-excel',
'xm' => 'audio/xm',
@@ -1183,7 +1254,8 @@ final class MimeType
'xpw' => 'application/vnd.intercon.formnet',
'xpx' => 'application/vnd.intercon.formnet',
'xsd' => 'application/xml',
'xsl' => 'application/xml',
'xsf' => 'application/prs.xsf+xml',
'xsl' => 'application/xslt+xml',
'xslt' => 'application/xslt+xml',
'xsm' => 'application/vnd.syncml+xml',
'xspf' => 'application/xspf+xml',
@@ -1216,9 +1288,9 @@ final class MimeType
];
/**
* Determines the mimetype of a file by looking at its extension.
* Determines the MIME type of a file by looking at its extension.
*
* @link https://raw.githubusercontent.com/jshttp/mime-db/master/db.json
* @see https://raw.githubusercontent.com/jshttp/mime-db/master/db.json
*/
public static function fromFilename(string $filename): ?string
{
@@ -1226,12 +1298,12 @@ final class MimeType
}
/**
* Maps a file extensions to a mimetype.
* Maps a file extension to a MIME type.
*
* @link https://raw.githubusercontent.com/jshttp/mime-db/master/db.json
* @see https://raw.githubusercontent.com/jshttp/mime-db/master/db.json
*/
public static function fromExtension(string $extension): ?string
{
return self::MIME_TYPES[strtolower($extension)] ?? null;
return self::MIME_TYPES[Utils::asciiToLower($extension)] ?? null;
}
}
@@ -13,28 +13,40 @@ use Psr\Http\Message\StreamInterface;
final class MultipartStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var string */
private $boundary;
private string $boundary;
/** @var StreamInterface */
private $stream;
private StreamInterface $stream;
private const BOUNDARY_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'()+_,-./:=? ";
/**
* @param array $elements Array of associative arrays, each containing a
* required "name" key mapping to the form field,
* name, a required "contents" key mapping to a
* StreamInterface/resource/string, an optional
* "headers" associative array of custom headers,
* and an optional "filename" key mapping to a
* string to send as the filename in the part.
* @param string $boundary You can optionally provide a specific boundary
* @param array $elements Array of associative arrays, each containing a
* required "name" key mapping to the form field,
* name, a required "contents" key mapping to any
* non-array value accepted by Utils::streamFor()
* (non-string scalar field values are cast to
* string), or an array for nested expansion.
* Optional keys include "headers" (associative
* array of custom headers) and "filename" (string
* to send as the filename in the part).
* When "contents" is an array, it is recursively
* expanded into multiple fields using bracket notation
* (e.g., name[0][key]). Empty arrays produce no fields.
* The "filename" and "headers" options cannot be used
* with array contents.
* @param string|null $boundary You can optionally provide a specific boundary
*
* @throws \InvalidArgumentException
*/
public function __construct(array $elements = [], string $boundary = null)
public function __construct(array $elements = [], ?string $boundary = null)
{
$this->boundary = $boundary ?: bin2hex(random_bytes(20));
if ($boundary !== null) {
self::validateBoundary($boundary);
}
$this->boundary = $boundary ?? bin2hex(random_bytes(20));
$this->stream = $this->createStream($elements);
}
@@ -51,16 +63,21 @@ final class MultipartStream implements StreamInterface
/**
* Get the headers needed before transferring the content of a POST file
*
* @param array<string, string> $headers
* @param array<array-key, string> $headers
*/
private function getHeaders(array $headers): string
{
$str = '';
foreach ($headers as $key => $value) {
$key = (string) $key;
self::validatePartHeaderName($key);
self::validatePartHeaderValue($key, $value);
$str .= "{$key}: {$value}\r\n";
}
return "--{$this->boundary}\r\n" . trim($str) . "\r\n\r\n";
return "--{$this->boundary}\r\n".rtrim($str, "\r\n")."\r\n\r\n";
}
/**
@@ -72,7 +89,7 @@ final class MultipartStream implements StreamInterface
foreach ($elements as $element) {
if (!is_array($element)) {
throw new \UnexpectedValueException("An array is expected");
throw new \UnexpectedValueException('An array is expected');
}
$this->addElement($stream, $element);
}
@@ -91,17 +108,45 @@ final class MultipartStream implements StreamInterface
}
}
$element['contents'] = Utils::streamFor($element['contents']);
if (!is_string($element['name']) && !is_int($element['name'])) {
throw new \InvalidArgumentException("The 'name' key must be a string or integer");
}
if (is_array($element['contents'])) {
if (array_key_exists('filename', $element) || array_key_exists('headers', $element)) {
throw new \InvalidArgumentException(
"The 'filename' and 'headers' options cannot be used when 'contents' is an array"
);
}
$this->addNestedElements($stream, $element['contents'], (string) $element['name']);
return;
}
$contents = $element['contents'];
if (is_scalar($contents) && !is_string($contents)) {
// Multipart field values are byte strings on the wire, so finite
// numeric and boolean field values are cast to string here rather
// than rejected by streamFor(). Non-finite floats cannot be
// represented and are rejected.
if (is_float($contents) && !is_finite($contents)) {
throw new \InvalidArgumentException('Cannot create a stream from a non-finite float.');
}
$contents = (string) $contents;
}
$element['contents'] = Utils::streamFor($contents);
if (empty($element['filename'])) {
$uri = $element['contents']->getMetadata('uri');
if ($uri && \is_string($uri) && \substr($uri, 0, 6) !== 'php://' && \substr($uri, 0, 7) !== 'data://') {
if ($uri && \is_string($uri) && !str_starts_with($uri, 'php://') && !str_starts_with($uri, 'data://')) {
$element['filename'] = $uri;
}
}
[$body, $headers] = $this->createElement(
$element['name'],
(string) $element['name'],
$element['contents'],
$element['filename'] ?? null,
$element['headers'] ?? []
@@ -112,48 +157,130 @@ final class MultipartStream implements StreamInterface
$stream->addStream(Utils::streamFor("\r\n"));
}
/**
* Recursively expand array contents into multiple form fields.
*
* @param array<array-key, mixed> $contents
*/
private function addNestedElements(AppendStream $stream, array $contents, string $root): void
{
foreach ($contents as $key => $value) {
$fieldName = $root === '' ? sprintf('[%s]', (string) $key) : sprintf('%s[%s]', $root, (string) $key);
if (is_array($value)) {
$this->addNestedElements($stream, $value, $fieldName);
} else {
$this->addElement($stream, ['name' => $fieldName, 'contents' => $value]);
}
}
}
/**
* @param array<array-key, mixed> $headers
*
* @return array{0: StreamInterface, 1: array<array-key, string>}
*/
private function createElement(string $name, StreamInterface $stream, ?string $filename, array $headers): array
{
$headers = self::normalizePartHeaders($headers);
// Set a default content-disposition header if one was no provided
$disposition = $this->getHeader($headers, 'content-disposition');
$disposition = self::getHeader($headers, 'content-disposition');
if (!$disposition) {
$escapedName = self::escapeContentDispositionParameter($name);
$headers['Content-Disposition'] = ($filename === '0' || $filename)
? sprintf(
'form-data; name="%s"; filename="%s"',
$name,
basename($filename)
$escapedName,
self::escapeContentDispositionParameter(basename($filename))
)
: "form-data; name=\"{$name}\"";
}
// Set a default content-length header if one was no provided
$length = $this->getHeader($headers, 'content-length');
if (!$length) {
if ($length = $stream->getSize()) {
$headers['Content-Length'] = (string) $length;
}
: sprintf('form-data; name="%s"', $escapedName);
}
// Set a default Content-Type if one was not supplied
$type = $this->getHeader($headers, 'content-type');
$type = self::getHeader($headers, 'content-type');
if (!$type && ($filename === '0' || $filename)) {
if ($type = MimeType::fromFilename($filename)) {
$headers['Content-Type'] = $type;
}
$headers['Content-Type'] = MimeType::fromFilename($filename) ?? 'application/octet-stream';
}
return [$stream, $headers];
}
private function getHeader(array $headers, string $key)
/**
* @param array<array-key, string> $headers
*/
private static function getHeader(array $headers, string $key): ?string
{
$lowercaseHeader = strtolower($key);
$lowercaseHeader = Utils::asciiToLower($key);
foreach ($headers as $k => $v) {
if (strtolower($k) === $lowercaseHeader) {
if (Utils::asciiToLower((string) $k) === $lowercaseHeader) {
return $v;
}
}
return null;
}
private static function validateBoundary(string $boundary): void
{
$length = strlen($boundary);
if ($length < 1 || $length > 70 || $boundary[$length - 1] === ' ') {
throw new \InvalidArgumentException('Invalid multipart boundary.');
}
if (strspn($boundary, self::BOUNDARY_CHARS) !== $length) {
throw new \InvalidArgumentException('Invalid multipart boundary.');
}
}
/**
* @param array<array-key, mixed> $headers
*
* @return array<array-key, string>
*/
private static function normalizePartHeaders(array $headers): array
{
$normalized = [];
foreach ($headers as $key => $value) {
$key = (string) $key;
self::validatePartHeaderName($key);
if (!is_string($value)) {
throw new \InvalidArgumentException('Multipart part header value must be a string.');
}
self::validatePartHeaderValue($key, $value);
$normalized[$key] = $value;
}
return $normalized;
}
private static function validatePartHeaderName(string $name): void
{
if (!Rfc9110::isToken($name)) {
throw new \InvalidArgumentException(sprintf('Invalid multipart part header name: %s', DiagnosticValue::escape($name)));
}
}
private static function validatePartHeaderValue(string $name, string $value): void
{
if (!Rfc9110::isFieldValue($value)) {
$reason = strpbrk($value, "\r\n") !== false
? 'must not contain CR or LF characters'
: 'contains an invalid control character';
throw new \InvalidArgumentException(sprintf('Multipart part header "%s" %s.', DiagnosticValue::escape($name), $reason));
}
}
private static function escapeContentDispositionParameter(string $value): string
{
// Match WHATWG browser multipart/form-data behavior: escape CR, LF, and DQUOTE only.
return str_replace(["\r", "\n", '"'], ['%0D', '%0A', '%22'], $value);
}
}
@@ -12,11 +12,11 @@ use Psr\Http\Message\StreamInterface;
final class NoSeekStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var StreamInterface */
private $stream;
private StreamInterface $stream;
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
throw new \RuntimeException('Cannot seek a NoSeekStream');
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* @internal
*/
trait NonSerializableStreamTrait
{
public function __serialize(): array
{
throw new \LogicException(static::class.' should never be serialized');
}
public function __unserialize(array $data): void
{
throw new \LogicException(static::class.' should never be unserialized');
}
}
+66 -55
View File
@@ -9,59 +9,67 @@ use Psr\Http\Message\StreamInterface;
/**
* Provides a read only stream that pumps data from a PHP callable.
*
* When invoking the provided callable, the PumpStream will pass the amount of
* data requested to read to the callable. The callable can choose to ignore
* When invoking the provided callable, the PumpStream will pass the suggested
* number of bytes to read to the callable. The callable can choose to ignore
* this value and return fewer or more bytes than requested. Any extra data
* returned by the provided callable is buffered internally until drained using
* the read() function of the PumpStream. The provided callable MUST return
* false when there is no more data to read.
* returned by the callable is buffered internally until drained using the
* read() function of the PumpStream. The callable MUST return a non-empty
* string when data is available, or false or null when there is no more data
* to read.
*
* Userland callables that declare no parameters are tolerated by PHP, but
* length-aware callables remain the recommended formal shape.
*/
final class PumpStream implements StreamInterface
{
use NonSerializableStreamTrait;
/** @var callable|null */
private $source;
/** @var int|null */
private $size;
private ?int $size;
/** @var int */
private $tellPos = 0;
private int $tellPos = 0;
/** @var array */
private $metadata;
private array $metadata;
/** @var BufferStream */
private $buffer;
private BufferStream $buffer;
/**
* @param callable(int): (string|null|false) $source Source of the stream data. The callable MAY
* accept an integer argument used to control the
* amount of data to return. The callable MUST
* return a string when called, or false|null on error
* or EOF.
* @param array{size?: int, metadata?: array} $options Stream options:
* - metadata: Hash of metadata to use with stream.
* - size: Size of the stream, if known.
* @param (callable(): (string|false|null))|(callable(int): (string|false|null)) $source Source of the stream data. The callable receives
* the suggested number of bytes to read, may ignore
* that value, and may return fewer or more bytes.
* Extra bytes are buffered. The callable MUST return
* a non-empty string when producing data, or false|null
* on error or EOF. Userland callables that declare no
* parameters are tolerated by PHP, but length-aware
* callables remain the recommended formal shape.
* @param array{size?: int, metadata?: array} $options Stream options:
* - metadata: Hash of metadata to use with stream.
* - size: Size of the stream, if known.
*/
public function __construct(callable $source, array $options = [])
{
$this->source = $source;
$this->size = $options['size'] ?? null;
$this->size = Integers::assertOptionalNonNegativeSize($options['size'] ?? null, 'Stream size');
$this->metadata = $options['metadata'] ?? [];
$this->buffer = new BufferStream();
}
public function __unserialize(array $data): void
{
$this->source = null;
$this->size = null;
$this->tellPos = 0;
$this->metadata = [];
$this->buffer = new BufferStream();
throw new \LogicException(static::class.' should never be unserialized');
}
public function __toString(): string
{
try {
return Utils::copyToString($this);
} catch (\Throwable $e) {
if (\PHP_VERSION_ID >= 70400) {
throw $e;
}
trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR);
return '';
}
return Utils::copyToString($this);
}
public function close(): void
@@ -73,6 +81,7 @@ final class PumpStream implements StreamInterface
{
$this->tellPos = 0;
$this->source = null;
$this->buffer->close();
return null;
}
@@ -102,7 +111,7 @@ final class PumpStream implements StreamInterface
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
throw new \RuntimeException('Cannot seek a PumpStream');
}
@@ -112,7 +121,7 @@ final class PumpStream implements StreamInterface
return false;
}
public function write($string): int
public function write(string $string): int
{
throw new \RuntimeException('Cannot write to a PumpStream');
}
@@ -122,40 +131,35 @@ final class PumpStream implements StreamInterface
return true;
}
public function read($length): string
public function read(int $length): string
{
$data = $this->buffer->read($length);
$readLen = strlen($data);
$this->tellPos += $readLen;
$remaining = $length - $readLen;
if ($remaining) {
$this->pump($remaining);
$data .= $this->buffer->read($remaining);
$this->tellPos += strlen($data) - $readLen;
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
$bufferLength = $this->buffer->getSize() ?? 0;
if ($length > $bufferLength) {
$this->pump($length - $bufferLength);
}
$data = $this->buffer->read($length);
$this->tellPos = Integers::add($this->tellPos, strlen($data));
return $data;
}
public function getContents(): string
{
$result = '';
while (!$this->eof()) {
$result .= $this->read(1000000);
}
return $result;
return Utils::copyToString($this);
}
/**
* {@inheritdoc}
*
* @return mixed
*/
public function getMetadata($key = null)
public function getMetadata(?string $key = null)
{
if (!$key) {
if ($key === null) {
return $this->metadata;
}
@@ -164,13 +168,20 @@ final class PumpStream implements StreamInterface
private function pump(int $length): void
{
if ($this->source) {
if ($this->source !== null) {
do {
$data = call_user_func($this->source, $length);
/** @var string|false|null $data */
$data = ($this->source)($length);
if ($data === false || $data === null) {
$this->source = null;
return;
}
if ($data === '') {
throw new \RuntimeException('PumpStream source returned an empty string');
}
$this->buffer->write($data);
$length -= strlen($data);
} while ($length > 0);
+65 -22
View File
@@ -6,13 +6,17 @@ namespace GuzzleHttp\Psr7;
final class Query
{
private function __construct()
{
}
/**
* Parse a query string into an associative array.
*
* If multiple values are found for the same key, the value of that key
* value pair will become an array. This function does not parse nested
* PHP style arrays into an associative array (e.g., `foo[a]=1&foo[b]=2`
* will be parsed into `['foo[a]' => '1', 'foo[b]' => '2'])`.
* If multiple values are found for the same key, the value of that
* key-value pair becomes an array. This function does not parse nested PHP
* style arrays into an associative array. For example, `foo[a]=1&foo[b]=2`
* will be parsed into `['foo[a]' => '1', 'foo[b]' => '2']`.
*
* @param string $str Query string to parse
* @param int|bool $urlEncoding How the query string is encoded
@@ -26,15 +30,19 @@ final class Query
}
if ($urlEncoding === true) {
$decoder = function ($value) {
return rawurldecode(str_replace('+', ' ', (string) $value));
$decoder = function (string $value): string {
return \rawurldecode(str_replace('+', ' ', $value));
};
} elseif ($urlEncoding === PHP_QUERY_RFC3986) {
$decoder = 'rawurldecode';
$decoder = static function (string $value): string {
return \rawurldecode($value);
};
} elseif ($urlEncoding === PHP_QUERY_RFC1738) {
$decoder = 'urldecode';
$decoder = static function (string $value): string {
return \urldecode($value);
};
} else {
$decoder = function ($str) {
$decoder = function (string $str): string {
return $str;
};
}
@@ -57,18 +65,21 @@ final class Query
}
/**
* Build a query string from an array of key value pairs.
* Build a query string from an array of key-value pairs.
*
* This function can use the return value of `parse()` to build a query
* string. This function does not modify the provided keys when an array is
* encountered (like `http_build_query()` would).
* encountered, unlike `http_build_query()`.
*
* @param array $params Query string parameters.
* @param int|false $encoding Set to false to not encode, PHP_QUERY_RFC3986
* to encode using RFC3986, or PHP_QUERY_RFC1738
* to encode using RFC1738.
* @param array $params Query string parameters.
* @param int|false $encoding Set to false to not encode,
* PHP_QUERY_RFC3986 to encode using
* RFC3986, or PHP_QUERY_RFC1738 to
* encode using RFC1738.
* @param bool $treatBoolsAsInts Set to true to encode as 0/1, and
* false as false/true.
*/
public static function build(array $params, $encoding = PHP_QUERY_RFC3986): string
public static function build(array $params, $encoding = PHP_QUERY_RFC3986, bool $treatBoolsAsInts = true): string
{
if (!$params) {
return '';
@@ -79,9 +90,13 @@ final class Query
return $str;
};
} elseif ($encoding === PHP_QUERY_RFC3986) {
$encoder = 'rawurlencode';
$encoder = static function (string $value): string {
return \rawurlencode($value);
};
} elseif ($encoding === PHP_QUERY_RFC1738) {
$encoder = 'urlencode';
$encoder = static function (string $value): string {
return \urlencode($value);
};
} else {
throw new \InvalidArgumentException('Invalid type');
}
@@ -91,17 +106,17 @@ final class Query
$k = $encoder((string) $k);
if (!is_array($v)) {
$qs .= $k;
$v = is_bool($v) ? (int) $v : $v;
$v = self::normalizeValue($v, $treatBoolsAsInts);
if ($v !== null) {
$qs .= '=' . $encoder((string) $v);
$qs .= '='.$encoder($v);
}
$qs .= '&';
} else {
foreach ($v as $vv) {
$qs .= $k;
$vv = is_bool($vv) ? (int) $vv : $vv;
$vv = self::normalizeValue($vv, $treatBoolsAsInts);
if ($vv !== null) {
$qs .= '=' . $encoder((string) $vv);
$qs .= '='.$encoder($vv);
}
$qs .= '&';
}
@@ -110,4 +125,32 @@ final class Query
return $qs ? (string) substr($qs, 0, -1) : '';
}
/**
* @param mixed $value
*/
private static function normalizeValue($value, bool $treatBoolsAsInts): ?string
{
if ($value === null) {
return null;
}
if (is_bool($value)) {
return $treatBoolsAsInts ? (string) (int) $value : ($value ? 'true' : 'false');
}
if (is_float($value) && !is_finite($value)) {
throw new \InvalidArgumentException('Query string values must be finite; non-finite floats are not supported.');
}
if (is_scalar($value)) {
return (string) $value;
}
if (is_object($value) && method_exists($value, '__toString')) {
return $value->__toString();
}
throw new \InvalidArgumentException('Query string values must be scalar, null, or stringable objects');
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use Psr\Http\Message\StreamInterface;
/**
* Stream decorator that records read failures and returns an empty string.
*
* InflateStream reads through it so that a failure part way through a filtered
* fread() cannot discard bytes that were already decoded.
*
* @internal
*/
final class ReadFailureCapturingStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
private StreamInterface $stream;
private ?\Exception $readFailure = null;
private bool $capturing = true;
public function read(int $length): string
{
if (!$this->capturing) {
return $this->stream->read($length);
}
try {
return $this->stream->read($length);
} catch (\Exception $e) {
$this->readFailure = $e;
return '';
}
}
public function stopCapturing(): void
{
$this->capturing = false;
$this->readFailure = null;
}
public function hasReadFailure(): bool
{
return $this->readFailure !== null;
}
public function takeReadFailure(): ?\Exception
{
$failure = $this->readFailure;
$this->readFailure = null;
return $failure;
}
}
+94 -41
View File
@@ -16,19 +16,16 @@ class Request implements RequestInterface
{
use MessageTrait;
/** @var string */
private $method;
private string $method;
/** @var string|null */
private $requestTarget;
private ?string $requestTarget = null;
/** @var UriInterface */
private $uri;
private UriInterface $uri;
/**
* @param string $method HTTP method
* @param string|UriInterface $uri URI
* @param array<string, string|string[]> $headers Request headers
* @param (string|string[])[] $headers Request headers
* @param string|resource|StreamInterface|null $body Request body
* @param string $version Protocol version
*/
@@ -40,11 +37,14 @@ class Request implements RequestInterface
string $version = '1.1'
) {
$this->assertMethod($method);
if (!($uri instanceof UriInterface)) {
$this->assertProtocolVersion($version);
if (!$uri instanceof UriInterface) {
$uri = new Uri($uri);
}
self::getRequestTargetFromUri($uri);
$this->method = strtoupper($method);
$this->method = $method;
$this->uri = $uri;
$this->setHeaders($headers);
$this->protocol = $version;
@@ -64,27 +64,16 @@ class Request implements RequestInterface
return $this->requestTarget;
}
$target = $this->uri->getPath();
if ($target === '') {
$target = '/';
}
if ($this->uri->getQuery() != '') {
$target .= '?' . $this->uri->getQuery();
}
return $target;
return self::getRequestTargetFromUri($this->uri);
}
public function withRequestTarget($requestTarget): RequestInterface
public function withRequestTarget(string $requestTarget): RequestInterface
{
if (preg_match('#\s#', $requestTarget)) {
throw new InvalidArgumentException(
'Invalid request target provided; cannot contain whitespace'
);
}
self::assertRequestTarget($requestTarget);
$new = clone $this;
$new->requestTarget = $requestTarget;
return $new;
}
@@ -93,11 +82,12 @@ class Request implements RequestInterface
return $this->method;
}
public function withMethod($method): RequestInterface
public function withMethod(string $method): RequestInterface
{
$this->assertMethod($method);
$new = clone $this;
$new->method = strtoupper($method);
$new->method = $method;
return $new;
}
@@ -106,17 +96,30 @@ class Request implements RequestInterface
return $this->uri;
}
public function withUri(UriInterface $uri, $preserveHost = false): RequestInterface
public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface
{
if ($uri === $this->uri) {
$sameUri = $uri === $this->uri;
if (!$sameUri && $this->requestTarget === null) {
self::getRequestTargetFromUri($uri);
}
$currentHost = $this->getHeaderLine('Host');
$host = null;
if (!$preserveHost || $currentHost === '') {
$host = $this->getHostFromUri($uri);
}
if ($sameUri && ($host === null || $currentHost === $host)) {
return $this;
}
$new = clone $this;
$new->uri = $uri;
if (!$preserveHost || !isset($this->headerNames['host'])) {
$new->updateHostFromUri();
if ($host !== null) {
$new->setHostHeader($host);
}
return $new;
@@ -124,16 +127,36 @@ class Request implements RequestInterface
private function updateHostFromUri(): void
{
$host = $this->uri->getHost();
$host = $this->getHostFromUri($this->uri);
if ($host == '') {
if ($host === null) {
return;
}
if (($port = $this->uri->getPort()) !== null) {
$host .= ':' . $port;
$this->setHostHeader($host);
}
private function getHostFromUri(UriInterface $uri): ?string
{
$host = $uri->getHost();
if ($host === '') {
return null;
}
Uri::assertValidHost($host);
if (($port = $uri->getPort()) !== null) {
$host .= ':'.$port;
}
$this->assertValue('Host', $host);
return $host;
}
private function setHostHeader(string $host): void
{
if (isset($this->headerNames['host'])) {
$header = $this->headerNames['host'];
} else {
@@ -141,17 +164,47 @@ class Request implements RequestInterface
$this->headerNames['host'] = 'Host';
}
// Ensure Host is the first header.
// See: http://tools.ietf.org/html/rfc7230#section-5.4
// See: https://datatracker.ietf.org/doc/html/rfc9110#section-7.2
$this->headers = [$header => [$host]] + $this->headers;
}
/**
* @param mixed $method
*/
private function assertMethod($method): void
private function assertMethod(string $method): void
{
if (!is_string($method) || $method === '') {
throw new InvalidArgumentException('Method must be a non-empty string.');
if (!Rfc9110::isToken($method)) {
throw new InvalidArgumentException('Method must be a valid HTTP token.');
}
}
private static function getRequestTargetFromUri(UriInterface $uri): string
{
$target = self::normalizePathForOriginForm($uri->getPath());
if ($target === '') {
$target = '/';
}
if ($uri->getQuery() != '') {
$target .= '?'.$uri->getQuery();
}
self::assertRequestTarget($target);
return $target;
}
private static function assertRequestTarget(string $requestTarget): void
{
if (!Rfc9112::isValidRequestTarget($requestTarget)) {
throw new InvalidArgumentException(
'Invalid request target provided; cannot be empty or contain whitespace or control characters'
);
}
}
private static function normalizePathForOriginForm(string $path): string
{
if (str_starts_with($path, '//')) {
return '/'.ltrim($path, '/');
}
return $path;
}
}
+22 -23
View File
@@ -78,15 +78,13 @@ class Response implements ResponseInterface
511 => 'Network Authentication Required',
];
/** @var string */
private $reasonPhrase;
private string $reasonPhrase;
/** @var int */
private $statusCode;
private int $statusCode;
/**
* @param int $status Status code
* @param array<string, string|string[]> $headers Response headers
* @param (string|string[])[] $headers Response headers
* @param string|resource|StreamInterface|null $body Response body
* @param string $version Protocol version
* @param string|null $reason Reason phrase (when empty a default will be used based on the status code)
@@ -96,9 +94,10 @@ class Response implements ResponseInterface
array $headers = [],
$body = null,
string $version = '1.1',
string $reason = null
?string $reason = null
) {
$this->assertStatusCodeRange($status);
$this->assertProtocolVersion($version);
$this->statusCode = $status;
@@ -108,11 +107,14 @@ class Response implements ResponseInterface
$this->setHeaders($headers);
if ($reason == '' && isset(self::PHRASES[$this->statusCode])) {
$this->reasonPhrase = self::PHRASES[$this->statusCode];
$reasonPhrase = self::PHRASES[$this->statusCode];
} else {
$this->reasonPhrase = (string) $reason;
$reasonPhrase = (string) $reason;
}
$this->assertReasonPhrase($reasonPhrase);
$this->reasonPhrase = $reasonPhrase;
$this->protocol = $version;
}
@@ -126,29 +128,19 @@ class Response implements ResponseInterface
return $this->reasonPhrase;
}
public function withStatus($code, $reasonPhrase = ''): ResponseInterface
public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface
{
$this->assertStatusCodeIsInteger($code);
$code = (int) $code;
$this->assertStatusCodeRange($code);
$new = clone $this;
$new->statusCode = $code;
if ($reasonPhrase == '' && isset(self::PHRASES[$new->statusCode])) {
if ($reasonPhrase === '' && isset(self::PHRASES[$new->statusCode])) {
$reasonPhrase = self::PHRASES[$new->statusCode];
}
$new->reasonPhrase = (string) $reasonPhrase;
return $new;
}
$this->assertReasonPhrase($reasonPhrase);
$new->reasonPhrase = $reasonPhrase;
/**
* @param mixed $statusCode
*/
private function assertStatusCodeIsInteger($statusCode): void
{
if (filter_var($statusCode, FILTER_VALIDATE_INT) === false) {
throw new \InvalidArgumentException('Status code must be an integer value.');
}
return $new;
}
private function assertStatusCodeRange(int $statusCode): void
@@ -157,4 +149,11 @@ class Response implements ResponseInterface
throw new \InvalidArgumentException('Status code must be an integer value between 1xx and 5xx.');
}
}
private function assertReasonPhrase(string $reasonPhrase): void
{
if (!Rfc9112::isValidReasonPhrase($reasonPhrase)) {
throw new \InvalidArgumentException('Reason phrase must not contain invalid control characters.');
}
}
}
+361
View File
@@ -0,0 +1,361 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* Syntax predicates and canonicalization helpers for the URI grammar defined
* by RFC 3986.
*/
final class Rfc3986
{
private function __construct()
{
}
/**
* Sub-delims for use in a regex.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
*
* @internal
*/
public const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
/**
* Unreserved characters for use in a regex.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.3
*
* @internal
*/
public const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~';
/**
* The two hex digits of a percent-encoded octet (the "3A" in "%3A"), for use in a regex.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.1
*
* @internal
*/
public const HEX_OCTET = '[A-Fa-f0-9]{2}';
/**
* Whether the string is a valid URI scheme.
*
* Per RFC 3986 a scheme must start with a letter, followed by letters,
* digits, `+`, `-`, or `.`. The empty string is also accepted, since a URI
* reference may omit the scheme.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-3.1
*/
public static function isValidScheme(string $scheme): bool
{
return $scheme === '' || preg_match('/^[A-Za-z][A-Za-z0-9.+-]*$/D', $scheme) === 1;
}
/**
* Whether the string is a valid URI host.
*
* Per RFC 3986 the host is `IP-literal / IPv4address / reg-name`. An empty
* host is accepted, since the authority (and thus the host) may be empty.
* Bracketed values are validated as IPv6 / IPvFuture literals; any other
* value is rejected if it contains control characters, whitespace, an
* authority or path delimiter (`/ ? # @ \`), an embedded colon denoting
* a port, a malformed percent-sequence, or a percent-encoded octet that
* decodes to one of those rejected bytes, to a bracket, or to `%` itself.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.2
*/
public static function isValidHost(string $host): bool
{
if ($host === '') {
return true;
}
$invalidHost = preg_match('/[\x00-\x20\x7F\/\?#@\\\\]/', $host);
if ($invalidHost === false) {
return false;
}
if ($invalidHost === 1) {
return false;
}
if (str_contains($host, '[') || str_contains($host, ']')) {
return self::isValidIpLiteralHost($host);
}
if (str_contains($host, ':')) {
return false;
}
return !str_contains($host, '%') || self::hasValidHostPercentEncoding($host);
}
/**
* Whether the string is a valid port number (0-65535).
*
* RFC 3986 defines the port as `*DIGIT`, which also permits an empty port
* and has no upper bound. This applies the stricter policy used throughout
* the library instead: the value must be a non-empty run of digits (leading
* zeros are accepted and normalized) that resolves to 0-65535.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.3
*/
public static function isValidPort(string $port): bool
{
if ($port === '' || !ctype_digit($port)) {
return false;
}
$normalized = ltrim($port, '0');
if ($normalized === '') {
return true;
}
return strlen($normalized) <= 5 && (int) $normalized <= 0xFFFF;
}
/**
* Returns the RFC 5952 canonical form of a valid IPv6 address.
*
* The address must be a valid textual IPv6 address without brackets and
* without a zone identifier, such as the inside of an IP-literal accepted
* by `isValidHost()`. Canonicalization lowercases the hexadecimal fields,
* suppresses leading zeros, and collapses the longest run of two or more
* zero fields (the leftmost on a tie) with `::`. Embedded dotted-decimal
* notation follows the rendering policy of BIND-derived `inet_ntop()`
* implementations and curl: exactly the IPv4-mapped (`::ffff:0:0/96`) and
* deprecated IPv4-compatible (`::/96`) layouts use it, while other
* embedded-IPv4 forms, including translated (NAT64) well-known prefixes
* such as `64:ff9b::/96` (RFC 6052), serialize in pure hexadecimal fields.
*
* Validation is strict and platform-independent: the address is checked
* against the RFC 3986 `IPv6address` grammar with PHP's
* `FILTER_VALIDATE_IP` filter and parsed in pure PHP, so spellings that
* only some platform parsers accept, such as the zero-padded dotted octets
* in `::ffff:192.168.001.001`, are rejected everywhere.
*
* @throws \InvalidArgumentException If the address cannot be parsed.
*
* @see https://datatracker.ietf.org/doc/html/rfc5952#section-4
*/
public static function canonicalizeIpv6(string $address): string
{
$canonical = self::tryCanonicalizeIpv6($address);
if ($canonical === null) {
throw new \InvalidArgumentException('Invalid IPv6 address');
}
return $canonical;
}
/**
* Returns the RFC 5952 canonical form of a valid IPv6 address, or null
* when the address cannot be parsed.
*
* @internal
*/
public static function tryCanonicalizeIpv6(string $address): ?string
{
// Platform parsers disagree on which spellings are valid: Apple libc
// and OpenBSD inet_pton() accept zero-padded dotted octets such as
// "::ffff:192.168.001.001", and macOS additionally accepts and silently
// strips zone IDs ("fe80::1%eth0"), while glibc, musl, and PHP's own
// filter reject both. Origin classification built on this helper must
// fail closed and must not vary by operating system, so the address is
// validated with the platform-independent FILTER_VALIDATE_IP filter and
// parsed in pure PHP; no OS parser is consulted.
if (\filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6) === false) {
return null;
}
$words = self::parseIpv6Words($address);
if ($words === null) {
return null;
}
// Find the longest run of two or more zero fields; ties keep the
// leftmost run per RFC 5952 section 4.2.3.
$bestStart = 0;
$bestLen = 0;
$start = -1;
foreach ($words as $i => $word) {
if ($word !== 0) {
$start = -1;
continue;
}
if ($start === -1) {
$start = $i;
}
if ($i - $start + 1 > $bestLen) {
$bestStart = $start;
$bestLen = $i - $start + 1;
}
}
if ($bestLen < 2) {
$bestLen = 0;
}
// RFC 5952 section 5: embedded IPv4 notation for IPv4-mapped
// (::ffff:0:0/96) and IPv4-compatible (::/96) addresses, the same
// condition BIND-derived inet_ntop() and curl use. bestStart must be
// zero: a five or six field zero run elsewhere is not an IPv4 prefix.
$mixed = $bestStart === 0
&& ($bestLen === 6 || ($bestLen === 5 && $words[5] === 0xFFFF));
$groups = [];
for ($i = 0, $n = $mixed ? 6 : 8; $i < $n; ++$i) {
$groups[] = dechex($words[$i]);
}
if ($mixed) {
$groups[] = sprintf(
'%d.%d.%d.%d',
$words[6] >> 8,
$words[6] & 0xFF,
$words[7] >> 8,
$words[7] & 0xFF
);
}
if ($bestLen === 0) {
return implode(':', $groups);
}
return implode(':', array_slice($groups, 0, $bestStart)).'::'.implode(':', array_slice($groups, $bestStart + $bestLen));
}
private static function hasValidHostPercentEncoding(string $host): bool
{
// Mirror of the raw reg-name policy above for percent-encoded octets:
// reject malformed sequences (RFC 3986 requires "%" HEXDIG HEXDIG) and
// octets that decode to bytes the raw grammar rejects - C0 controls,
// SP, DEL, the delimiters / ? # @ \ [ ], the port colon, and % itself.
// Octets decoding to any other byte (unreserved, sub-delims, and
// non-ASCII UTF-8 data) remain accepted.
$invalidEncoding = preg_match(
'/%(?!'.self::HEX_OCTET.')|%(?:[01][0-9A-Fa-f]|2[035F]|3[AF]|40|5[BCD]|7F)/i',
$host
);
return $invalidEncoding === 0;
}
private static function isValidIpLiteralHost(string $host): bool
{
if (!str_starts_with($host, '[') || !str_ends_with($host, ']')) {
return false;
}
$address = substr($host, 1, -1);
if (\filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6) !== false) {
return true;
}
// RFC 6874 IPv6 zone identifiers are intentionally not supported here.
// Bracketed hosts are validated as IPv6 or IPvFuture only.
return preg_match('/^v[0-9a-f]+\.['.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.':]+$/iD', $address) === 1;
}
/**
* Parses a textual IPv6 address into its eight 16-bit words, or null
* when the text is not a structurally valid address.
*
* The grammar enforced here is the RFC 3986 `IPv6address` rule: one to four
* hexadecimal digits per field, at most one `::` eliding one or more zero
* fields, and an optional dotted-decimal tail of four octets (0-255, no
* leading zeros) as the final 32 bits. FILTER_VALIDATE_IP accepts exactly
* this grammar, so the filter guard in tryCanonicalizeIpv6() and this
* parser always agree and the null paths here can only fail closed.
*
* @return list<int>|null
*/
private static function parseIpv6Words(string $address): ?array
{
// A dotted-decimal tail is only valid as the final 32 bits, after the
// final colon. Rewrite it into its two hexadecimal fields so the
// remainder of the parse handles hexadecimal fields only.
$dot = strpos($address, '.');
if ($dot !== false) {
$colon = strrpos($address, ':');
if ($colon === false || $colon > $dot) {
return null;
}
$octets = explode('.', substr($address, $colon + 1));
if (count($octets) !== 4) {
return null;
}
$bytes = [];
foreach ($octets as $octet) {
if ($octet === '' || strlen($octet) > 3 || !ctype_digit($octet)) {
return null;
}
if ($octet[0] === '0' && $octet !== '0') {
return null;
}
$byte = (int) $octet;
if ($byte > 255) {
return null;
}
$bytes[] = $byte;
}
$address = substr($address, 0, $colon + 1)
.dechex(($bytes[0] << 8) | $bytes[1])
.':'
.dechex(($bytes[2] << 8) | $bytes[3]);
}
$halves = explode('::', $address);
if (count($halves) > 2) {
return null;
}
$head = self::parseHexFields($halves[0]);
if ($head === null) {
return null;
}
if (count($halves) === 1) {
return count($head) === 8 ? $head : null;
}
$tail = self::parseHexFields($halves[1]);
if ($tail === null) {
return null;
}
// The "::" must elide at least one zero field.
$elided = 8 - count($head) - count($tail);
if ($elided < 1) {
return null;
}
return array_merge($head, array_fill(0, $elided, 0), $tail);
}
/**
* Parses a colon-separated run of 16-bit hexadecimal fields, or null
* when a field is empty, longer than four digits, or not hexadecimal.
*
* @return list<int>|null
*/
private static function parseHexFields(string $fields): ?array
{
if ($fields === '') {
return [];
}
$words = [];
foreach (explode(':', $fields) as $field) {
if ($field === '' || strlen($field) > 4 || !ctype_xdigit($field)) {
return null;
}
$words[] = intval($field, 16);
}
return $words;
}
}
-23
View File
@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* @internal
*/
final class Rfc7230
{
/**
* Header related regular expressions (based on amphp/http package)
*
* Note: header delimiter (\r\n) is modified to \r?\n to accept line feed only delimiters for BC reasons.
*
* @link https://github.com/amphp/http/blob/v1.0.1/src/Rfc7230.php#L12-L15
*
* @license https://github.com/amphp/http/blob/v1.0.1/LICENSE
*/
public const HEADER_REGEX = "(^([^()<>@,;:\\\"/[\]?={}\x01-\x20\x7F]++):[ \t]*+((?:[ \t]*+[\x21-\x7E\x80-\xFF]++)*+)[ \t]*+\r?\n)m";
public const HEADER_FOLD_REGEX = "(\r?\n[ \t]++)";
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* @internal
*/
final class Rfc9110
{
/**
* A token for use in a regular expression.
*
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2
*/
public const TOKEN_PATTERN = '[!#$%&\'*+.^_`|~0-9A-Za-z-]+';
/**
* A field value for use in a regular expression.
*
* Obsolete line folding is intentionally excluded.
*
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.5
*/
public const FIELD_VALUE_PATTERN = '[\x09\x20-\x7E\x80-\xFF]*';
private function __construct()
{
}
public static function isToken(string $value): bool
{
return preg_match('/^'.self::TOKEN_PATTERN.'$/D', $value) === 1;
}
public static function isFieldValue(string $value): bool
{
return preg_match('/^'.self::FIELD_VALUE_PATTERN.'$/D', $value) === 1;
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* @internal
*/
final class Rfc9112
{
/**
* An HTTP protocol version for use in a regular expression.
*/
public const PROTOCOL_VERSION_PATTERN = '\d+(?:\.\d+)?';
/**
* The request-target bytes accepted by the HTTP/1 start-line grammar for
* use in a regular expression.
*/
public const REQUEST_TARGET_PATTERN = '[^\x00-\x20\x7F]+';
private function __construct()
{
}
/**
* Header related regular expressions (based on amphp/http package)
*
* Note: header delimiter (\r\n) is modified to \r?\n to accept line feed only delimiters for BC reasons.
*
* @see https://github.com/amphp/http/blob/v1.0.1/src/Rfc7230.php#L12-L15
*
* @license https://github.com/amphp/http/blob/v1.0.1/LICENSE
*/
public const HEADER_REGEX = "(^([^()<>@,;:\\\"/[\]?={}\x01-\x20\x7F]++):[ \t]*+((?:[ \t]*+[\x21-\x7E\x80-\xFF]++)*+)[ \t]*+\r?\n)m";
public const HEADER_FOLD_REGEX = "(\r?\n[ \t]++)";
public static function isValidProtocolVersion(string $version): bool
{
return preg_match('/^'.self::PROTOCOL_VERSION_PATTERN.'$/D', $version) === 1;
}
public static function isValidRequestTarget(string $target): bool
{
return preg_match('/^'.self::REQUEST_TARGET_PATTERN.'$/D', $target) === 1;
}
public static function isValidReasonPhrase(string $reasonPhrase): bool
{
return Rfc9110::isFieldValue($reasonPhrase);
}
/**
* @return array{0: string, 1: int|null}|null
*/
public static function parseHostHeader(string $authority): ?array
{
if ($authority === '') {
return null;
}
$host = $authority;
$port = null;
if (str_starts_with($authority, '[')) {
$closingBracket = strpos($authority, ']');
if ($closingBracket === false) {
return null;
}
$host = substr($authority, 0, $closingBracket + 1);
$remainder = substr($authority, $closingBracket + 1);
if ($remainder !== '') {
if (!str_starts_with($remainder, ':')) {
return null;
}
$port = self::parsePort(substr($remainder, 1));
if ($port === null) {
return null;
}
}
} elseif (false !== ($colon = strpos($authority, ':'))) {
$host = substr($authority, 0, $colon);
$port = self::parsePort(substr($authority, $colon + 1));
if ($port === null) {
return null;
}
}
if ($host === '' || !Rfc3986::isValidHost($host)) {
return null;
}
return [$host, $port];
}
public static function isAbsoluteFormRequestTarget(string $target): bool
{
return preg_match('/^[A-Za-z][A-Za-z0-9+.-]*:\/\//D', $target) === 1;
}
public static function isAsteriskFormRequestTarget(string $method, string $target): bool
{
return $method === 'OPTIONS' && $target === '*';
}
public static function isConnectAuthorityFormRequestTarget(string $method, string $target): bool
{
return $method === 'CONNECT' && strpbrk($target, '/?#') === false;
}
public static function parsePort(string $port): ?int
{
if (!Rfc3986::isValidPort($port)) {
return null;
}
// A zero port is valid per RFC 3986 but meaningless for an HTTP
// authority, so reject it on top of the generic syntax check.
$parsed = (int) ltrim($port, '0');
return $parsed === 0 ? null : $parsed;
}
}
+58 -168
View File
@@ -26,40 +26,25 @@ use Psr\Http\Message\UriInterface;
*/
class ServerRequest extends Request implements ServerRequestInterface
{
/**
* @var array
*/
private $attributes = [];
private array $attributes = [];
/**
* @var array
*/
private $cookieParams = [];
private array $cookieParams = [];
/**
* @var array|object|null
*/
private $parsedBody;
/**
* @var array
*/
private $queryParams = [];
private array $queryParams = [];
/**
* @var array
*/
private $serverParams;
private array $serverParams;
/**
* @var array
*/
private $uploadedFiles = [];
private array $uploadedFiles = [];
/**
* @param string $method HTTP method
* @param string|UriInterface $uri URI
* @param array<string, string|string[]> $headers Request headers
* @param (string|string[])[] $headers Request headers
* @param string|resource|StreamInterface|null $body Request body
* @param string $version Protocol version
* @param array $serverParams Typically the $_SERVER superglobal
@@ -70,6 +55,7 @@ class ServerRequest extends Request implements ServerRequestInterface
array $headers = [],
$body = null,
string $version = '1.1',
#[\SensitiveParameter]
array $serverParams = []
) {
$this->serverParams = $serverParams;
@@ -86,73 +72,7 @@ class ServerRequest extends Request implements ServerRequestInterface
*/
public static function normalizeFiles(array $files): array
{
$normalized = [];
foreach ($files as $key => $value) {
if ($value instanceof UploadedFileInterface) {
$normalized[$key] = $value;
} elseif (is_array($value) && isset($value['tmp_name'])) {
$normalized[$key] = self::createUploadedFileFromSpec($value);
} elseif (is_array($value)) {
$normalized[$key] = self::normalizeFiles($value);
continue;
} else {
throw new InvalidArgumentException('Invalid value in files specification');
}
}
return $normalized;
}
/**
* Create and return an UploadedFile instance from a $_FILES specification.
*
* If the specification represents an array of values, this method will
* delegate to normalizeNestedFileSpec() and return that return value.
*
* @param array $value $_FILES struct
*
* @return UploadedFileInterface|UploadedFileInterface[]
*/
private static function createUploadedFileFromSpec(array $value)
{
if (is_array($value['tmp_name'])) {
return self::normalizeNestedFileSpec($value);
}
return new UploadedFile(
$value['tmp_name'],
(int) $value['size'],
(int) $value['error'],
$value['name'],
$value['type']
);
}
/**
* Normalize an array of file specifications.
*
* Loops through all nested files and returns a normalized array of
* UploadedFileInterface instances.
*
* @return UploadedFileInterface[]
*/
private static function normalizeNestedFileSpec(array $files = []): array
{
$normalizedFiles = [];
foreach (array_keys($files['tmp_name']) as $key) {
$spec = [
'tmp_name' => $files['tmp_name'][$key],
'size' => $files['size'][$key] ?? null,
'error' => $files['error'][$key] ?? null,
'name' => $files['name'][$key] ?? null,
'type' => $files['type'][$key] ?? null,
];
$normalizedFiles[$key] = self::createUploadedFileFromSpec($spec);
}
return $normalizedFiles;
return UploadedFileNormalizer::normalize($files);
}
/**
@@ -165,33 +85,20 @@ class ServerRequest extends Request implements ServerRequestInterface
*/
public static function fromGlobals(): ServerRequestInterface
{
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$headers = getallheaders();
$uri = self::getUriFromGlobals();
$body = new CachingStream(new LazyOpenStream('php://input', 'r+'));
$protocol = isset($_SERVER['SERVER_PROTOCOL']) ? str_replace('HTTP/', '', $_SERVER['SERVER_PROTOCOL']) : '1.1';
return ServerRequestGlobalsFactory::fromArrays(
$_SERVER,
$_GET,
$_POST,
$_COOKIE,
$_FILES,
static function () {
if (!\function_exists('apache_request_headers')) {
return false;
}
$serverRequest = new ServerRequest($method, $uri, $headers, $body, $protocol, $_SERVER);
return $serverRequest
->withCookieParams($_COOKIE)
->withQueryParams($_GET)
->withParsedBody($_POST)
->withUploadedFiles(self::normalizeFiles($_FILES));
}
private static function extractHostAndPortFromAuthority(string $authority): array
{
$uri = 'http://' . $authority;
$parts = parse_url($uri);
if (false === $parts) {
return [null, null];
}
$host = $parts['host'] ?? null;
$port = $parts['port'] ?? null;
return [$host, $port];
return \apache_request_headers();
}
);
}
/**
@@ -199,46 +106,7 @@ class ServerRequest extends Request implements ServerRequestInterface
*/
public static function getUriFromGlobals(): UriInterface
{
$uri = new Uri('');
$uri = $uri->withScheme(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http');
$hasPort = false;
if (isset($_SERVER['HTTP_HOST'])) {
[$host, $port] = self::extractHostAndPortFromAuthority($_SERVER['HTTP_HOST']);
if ($host !== null) {
$uri = $uri->withHost($host);
}
if ($port !== null) {
$hasPort = true;
$uri = $uri->withPort($port);
}
} elseif (isset($_SERVER['SERVER_NAME'])) {
$uri = $uri->withHost($_SERVER['SERVER_NAME']);
} elseif (isset($_SERVER['SERVER_ADDR'])) {
$uri = $uri->withHost($_SERVER['SERVER_ADDR']);
}
if (!$hasPort && isset($_SERVER['SERVER_PORT'])) {
$uri = $uri->withPort($_SERVER['SERVER_PORT']);
}
$hasQuery = false;
if (isset($_SERVER['REQUEST_URI'])) {
$requestUriParts = explode('?', $_SERVER['REQUEST_URI'], 2);
$uri = $uri->withPath($requestUriParts[0]);
if (isset($requestUriParts[1])) {
$hasQuery = true;
$uri = $uri->withQuery($requestUriParts[1]);
}
}
if (!$hasQuery && isset($_SERVER['QUERY_STRING'])) {
$uri = $uri->withQuery($_SERVER['QUERY_STRING']);
}
return $uri;
return ServerRequestGlobalsFactory::getUriFromServerParams($_SERVER);
}
public function getServerParams(): array
@@ -253,6 +121,26 @@ class ServerRequest extends Request implements ServerRequestInterface
public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface
{
$stack = [$uploadedFiles];
for ($i = 0; $i < \count($stack); ++$i) {
foreach ($stack[$i] as $uploadedFile) {
if ($uploadedFile instanceof UploadedFileInterface) {
continue;
}
if (\is_array($uploadedFile)) {
$stack[] = $uploadedFile;
continue;
}
throw new InvalidArgumentException(sprintf(
'Invalid uploaded file tree; expected UploadedFileInterface instances but %s provided.',
\get_debug_type($uploadedFile)
));
}
}
$new = clone $this;
$new->uploadedFiles = $uploadedFiles;
@@ -264,8 +152,10 @@ class ServerRequest extends Request implements ServerRequestInterface
return $this->cookieParams;
}
public function withCookieParams(array $cookies): ServerRequestInterface
{
public function withCookieParams(
#[\SensitiveParameter]
array $cookies
): ServerRequestInterface {
$new = clone $this;
$new->cookieParams = $cookies;
@@ -286,8 +176,6 @@ class ServerRequest extends Request implements ServerRequestInterface
}
/**
* {@inheritdoc}
*
* @return array|object|null
*/
public function getParsedBody()
@@ -297,6 +185,10 @@ class ServerRequest extends Request implements ServerRequestInterface
public function withParsedBody($data): ServerRequestInterface
{
if ($data !== null && !\is_array($data) && !\is_object($data)) {
throw new InvalidArgumentException('Parsed body must be an array, object, or null.');
}
$new = clone $this;
$new->parsedBody = $data;
@@ -309,35 +201,33 @@ class ServerRequest extends Request implements ServerRequestInterface
}
/**
* {@inheritdoc}
*
* @return mixed
*/
public function getAttribute($attribute, $default = null)
public function getAttribute(string $name, $default = null)
{
if (false === array_key_exists($attribute, $this->attributes)) {
if (false === array_key_exists($name, $this->attributes)) {
return $default;
}
return $this->attributes[$attribute];
return $this->attributes[$name];
}
public function withAttribute($attribute, $value): ServerRequestInterface
public function withAttribute(string $name, $value): ServerRequestInterface
{
$new = clone $this;
$new->attributes[$attribute] = $value;
$new->attributes[$name] = $value;
return $new;
}
public function withoutAttribute($attribute): ServerRequestInterface
public function withoutAttribute(string $name): ServerRequestInterface
{
if (false === array_key_exists($attribute, $this->attributes)) {
if (false === array_key_exists($name, $this->attributes)) {
return $this;
}
$new = clone $this;
unset($new->attributes[$attribute]);
unset($new->attributes[$name]);
return $new;
}
@@ -0,0 +1,504 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use InvalidArgumentException;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
/**
* @internal
*/
final class ServerRequestGlobalsFactory
{
private function __construct()
{
}
/**
* @param array<array-key, mixed> $server Typically the $_SERVER superglobal
* @param array<array-key, mixed> $query Typically the $_GET superglobal
* @param array<array-key, mixed> $post Typically the $_POST superglobal
* @param array<array-key, mixed> $cookies Typically the $_COOKIE superglobal
* @param array<array-key, mixed> $files Typically the $_FILES superglobal
* @param (callable(): mixed)|null $headerProvider
*/
public static function fromArrays(
#[\SensitiveParameter]
array $server,
array $query,
array $post,
#[\SensitiveParameter]
array $cookies,
array $files,
?callable $headerProvider = null
): ServerRequestInterface {
$method = self::getRequestMethodFromServer($server);
$headers = self::removeInvalidHostHeader(self::getAllHeaders($server, $headerProvider));
[$uri, $requestTarget] = self::getUriAndRequestTargetFromServer($server, $method);
$body = new CachingStream(new LazyOpenStream('php://input', 'r+'));
$serverRequest = new ServerRequest($method, $uri, $headers, $body, self::getProtocolFromServer($server), $server);
if ($requestTarget !== null) {
/** @var ServerRequestInterface $serverRequest */
$serverRequest = $serverRequest->withRequestTarget($requestTarget);
}
return $serverRequest
->withCookieParams($cookies)
->withQueryParams($query)
->withParsedBody($post)
->withUploadedFiles(UploadedFileNormalizer::normalize($files));
}
/**
* @param array<array-key, mixed> $server Typically the $_SERVER superglobal
*/
public static function getUriFromServerParams(
#[\SensitiveParameter]
array $server
): UriInterface {
$method = self::getRequestMethodFromServer($server);
return self::getUriAndRequestTargetFromServer($server, $method)[0];
}
/**
* @param array<array-key, mixed> $server
* @param (callable(): mixed)|null $headerProvider
*
* @return array<array-key, string>
*/
private static function getAllHeaders(
#[\SensitiveParameter]
array $server,
?callable $headerProvider
): array {
$headers = $headerProvider !== null ? $headerProvider() : false;
if (!is_array($headers)) {
$headers = self::getHeadersFromServer($server);
}
return self::normalizeHeaderValues($headers);
}
/**
* @param array<array-key, mixed> $headers
*
* @return array<array-key, string>
*/
private static function normalizeHeaderValues(
#[\SensitiveParameter]
array $headers
): array {
$normalized = [];
foreach ($headers as $name => $value) {
if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) {
$normalized[$name] = (string) $value;
}
}
return $normalized;
}
/**
* @param array<array-key, mixed> $server Typically the $_SERVER superglobal
*
* @return array<array-key, string>
*/
private static function getHeadersFromServer(array $server): array
{
$headers = [];
$copyServer = [
'CONTENT_TYPE' => 'Content-Type',
'CONTENT_LENGTH' => 'Content-Length',
'CONTENT_MD5' => 'Content-Md5',
];
foreach ($server as $key => $value) {
if (!is_string($key) || !is_string($value)) {
continue;
}
if (str_starts_with($key, 'HTTP_')) {
$header = substr($key, 5);
if (isset($copyServer[$header], $server[$header]) && is_string($server[$header])) {
continue;
}
$parts = explode(' ', Utils::asciiToLower(str_replace('_', ' ', $header)));
foreach ($parts as $i => $part) {
$parts[$i] = Utils::asciiUcFirst($part);
}
$header = implode('-', $parts);
$headers[$header] = $value;
continue;
}
if (isset($copyServer[$key])) {
$headers[$copyServer[$key]] = $value;
}
}
if (!isset($headers['Authorization'])) {
if (isset($server['REDIRECT_HTTP_AUTHORIZATION']) && is_string($server['REDIRECT_HTTP_AUTHORIZATION'])) {
$headers['Authorization'] = $server['REDIRECT_HTTP_AUTHORIZATION'];
} elseif (isset($server['PHP_AUTH_USER']) && is_string($server['PHP_AUTH_USER'])) {
$password = isset($server['PHP_AUTH_PW']) && is_string($server['PHP_AUTH_PW'])
? $server['PHP_AUTH_PW']
: '';
$headers['Authorization'] = 'Basic '.base64_encode($server['PHP_AUTH_USER'].':'.$password);
} elseif (isset($server['PHP_AUTH_DIGEST']) && is_string($server['PHP_AUTH_DIGEST'])) {
$headers['Authorization'] = $server['PHP_AUTH_DIGEST'];
}
}
return $headers;
}
/**
* @param array<array-key, string> $headers
*
* @return array<array-key, string>
*/
private static function removeInvalidHostHeader(array $headers): array
{
foreach ($headers as $name => $value) {
if (Utils::asciiToLower((string) $name) !== 'host') {
continue;
}
[$host] = self::extractHostAndPortFromAuthority($value);
if ($host === null) {
unset($headers[$name]);
}
}
return $headers;
}
/**
* @param array<array-key, mixed> $server
*/
private static function getServerParam(array $server, string $key): ?string
{
return isset($server[$key]) && is_string($server[$key]) ? $server[$key] : null;
}
/**
* @param array<array-key, mixed> $server
*/
private static function getRequestMethodFromServer(array $server): string
{
return Utils::asciiToUpper(self::getServerParam($server, 'REQUEST_METHOD') ?? 'GET');
}
/**
* @param array<array-key, mixed> $server
*/
private static function getProtocolFromServer(array $server): string
{
$serverProtocol = self::getServerParam($server, 'SERVER_PROTOCOL');
if ($serverProtocol === null) {
return '1.1';
}
return str_starts_with($serverProtocol, 'HTTP/') ? substr($serverProtocol, 5) : $serverProtocol;
}
/**
* @return array{0: string|null, 1: int|null}
*/
private static function extractHostAndPortFromAuthority(string $authority): array
{
return Rfc9112::parseHostHeader($authority) ?? [null, null];
}
private static function parseServerPort(string $port): int
{
$parsed = Rfc9112::parsePort($port);
if ($parsed === null) {
throw new InvalidArgumentException('Invalid SERVER_PORT; expected an integer between 1 and 65535.');
}
return $parsed;
}
private static function withHostFromServer(UriInterface $uri, ?string $host): ?UriInterface
{
if ($host === null) {
return null;
}
try {
return $uri->withHost($host);
} catch (InvalidArgumentException $e) {
return null;
}
}
/**
* @param array<array-key, mixed> $server
*/
private static function getUriWithSchemeFromServer(array $server): UriInterface
{
$uri = new Uri('');
$https = self::getServerParam($server, 'HTTPS');
return $uri->withScheme(!empty($https) && $https !== 'off' ? 'https' : 'http');
}
/**
* @param array<array-key, mixed> $server
*/
private static function getAuthorityUriFromServer(
#[\SensitiveParameter]
array $server
): UriInterface {
$uri = self::getUriWithSchemeFromServer($server);
$hasPort = false;
$hasHost = false;
$authority = self::getServerParam($server, 'HTTP_HOST');
if ($authority !== null) {
[$host, $port] = self::extractHostAndPortFromAuthority($authority);
if ($host !== null) {
$hostUri = self::withHostFromServer($uri, $host);
if ($hostUri !== null) {
$uri = $hostUri;
$hasHost = true;
if ($port !== null) {
$hasPort = true;
$uri = $uri->withPort($port);
}
}
}
}
foreach (['SERVER_NAME', 'SERVER_ADDR'] as $serverParam) {
if ($hasHost) {
continue;
}
$hostUri = self::withHostFromServer($uri, self::getServerParam($server, $serverParam));
if ($hostUri !== null) {
$uri = $hostUri;
$hasHost = true;
}
}
$serverPort = self::getServerParam($server, 'SERVER_PORT');
if (!$hasPort && $serverPort !== null) {
$uri = $uri->withPort(self::parseServerPort($serverPort));
}
return $uri;
}
/**
* @param array<array-key, mixed> $server
*
* @return array{0: UriInterface, 1: string|null}
*/
private static function getUriAndRequestTargetFromServer(
#[\SensitiveParameter]
array $server,
string $method
): array {
$requestUri = self::getServerParam($server, 'REQUEST_URI');
$queryString = self::getServerParam($server, 'QUERY_STRING');
if ($requestUri !== null) {
$connectAuthority = self::parseConnectAuthorityFormRequestTarget($method, $requestUri);
if ($connectAuthority !== null) {
[$host, $port] = $connectAuthority;
$uri = self::getUriWithSchemeFromServer($server);
return [
$uri->withHost($host)->withPort($port)->withPath('')->withQuery(''),
$requestUri,
];
}
$absoluteForm = self::getAbsoluteFormUriAndRequestTarget($requestUri, $queryString);
if ($absoluteForm !== null) {
return $absoluteForm;
}
}
$uri = self::getAuthorityUriFromServer($server);
if ($requestUri === null) {
if ($queryString !== null) {
$uri = $uri->withQuery($queryString);
}
return [$uri, null];
}
if (Rfc9112::isAsteriskFormRequestTarget($method, $requestUri)) {
return [$uri->withPath('')->withQuery(''), '*'];
}
[$path, $query, $hasQuery] = self::splitRequestTargetQuery($requestUri);
$uri = $uri->withPath(self::normalizeOriginFormPathFromServer($path));
if ($hasQuery) {
$uri = $uri->withQuery($query);
} elseif ($queryString !== null) {
$uri = $uri->withQuery($queryString);
}
return [$uri, null];
}
/**
* @return array{0: UriInterface, 1: string}|null
*/
private static function getAbsoluteFormUriAndRequestTarget(
#[\SensitiveParameter]
string $requestUri,
?string $queryString
): ?array {
if (!Rfc9112::isAbsoluteFormRequestTarget($requestUri)) {
return null;
}
try {
$targetUri = (new Uri($requestUri))->withFragment('');
} catch (InvalidArgumentException $e) {
return null;
}
if ($targetUri->getHost() === '') {
return null;
}
$requestTarget = self::removeRequestTargetFragment($requestUri);
$requestTargetWithoutUserInfo = self::removeUserInfoFromAbsoluteFormRequestTarget($requestTarget);
if ($requestTargetWithoutUserInfo !== $requestTarget) {
$targetUri = $targetUri->withUserInfo('');
$requestTarget = $requestTargetWithoutUserInfo;
}
if (!str_contains($requestTarget, '?') && $queryString !== null && $queryString !== '') {
$targetUri = $targetUri->withQuery($queryString);
$requestTarget .= '?'.$queryString;
}
// Preserve the received absolute-form target unless it cannot be used as
// a PSR-7 request target without normalization.
$normalizeRequestTarget = !Rfc9112::isValidRequestTarget($requestTarget)
|| self::hasEmptyPortInAbsoluteFormRequestTarget($requestTarget);
return [$targetUri, $normalizeRequestTarget ? (string) $targetUri : $requestTarget];
}
private static function removeUserInfoFromAbsoluteFormRequestTarget(string $target): string
{
$authorityStart = strpos($target, '://');
if ($authorityStart === false) {
return $target;
}
$authorityStart += 3;
$authorityLength = strcspn($target, '/?#', $authorityStart);
$authority = substr($target, $authorityStart, $authorityLength);
if ($authority === '') {
return $target;
}
$lastAt = strrpos($authority, '@');
if ($lastAt === false) {
return $target;
}
$authorityEnd = $authorityStart + $authorityLength;
return substr($target, 0, $authorityStart)
.substr($authority, $lastAt + 1)
.substr($target, $authorityEnd);
}
private static function hasEmptyPortInAbsoluteFormRequestTarget(string $target): bool
{
$authorityStart = strpos($target, '://');
if ($authorityStart === false) {
return false;
}
$authorityStart += 3;
$authority = substr($target, $authorityStart, strcspn($target, '/?#', $authorityStart));
if ($authority === '') {
return false;
}
$lastAt = strrpos($authority, '@');
if ($lastAt !== false) {
$authority = substr($authority, $lastAt + 1);
}
if ($authority === '') {
return false;
}
if (str_starts_with($authority, '[')) {
$closingBracket = strpos($authority, ']');
return $closingBracket !== false && substr($authority, $closingBracket + 1) === ':';
}
return str_ends_with($authority, ':');
}
/**
* @return array{0: string, 1: int}|null
*/
private static function parseConnectAuthorityFormRequestTarget(string $method, string $target): ?array
{
if (!Rfc9112::isConnectAuthorityFormRequestTarget($method, $target)) {
return null;
}
[$host, $port] = self::extractHostAndPortFromAuthority($target);
if ($host === null || $port === null) {
return null;
}
return [$host, $port];
}
private static function removeRequestTargetFragment(string $target): string
{
return explode('#', $target, 2)[0];
}
/**
* @return array{0: string, 1: string, 2: bool}
*/
private static function splitRequestTargetQuery(string $target): array
{
$parts = explode('?', $target, 2);
return [$parts[0], $parts[1] ?? '', isset($parts[1])];
}
private static function normalizeOriginFormPathFromServer(string $path): string
{
if ($path === '' || str_starts_with($path, '/')) {
return $path;
}
return '/'.$path;
}
}
+104 -52
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use Psr\Http\Message\StreamInterface;
/**
@@ -11,27 +12,17 @@ use Psr\Http\Message\StreamInterface;
*/
class Stream implements StreamInterface
{
/**
* @see http://php.net/manual/function.fopen.php
* @see http://php.net/manual/en/function.gzopen.php
*/
private const READABLE_MODES = '/r|a\+|ab\+|w\+|wb\+|x\+|xb\+|c\+|cb\+/';
private const WRITABLE_MODES = '/a|w|r\+|rb\+|rw|x|c/';
use NonSerializableStreamTrait;
/** @var resource */
private $stream;
/** @var int|null */
private $size;
/** @var bool */
private $seekable;
/** @var bool */
private $readable;
/** @var bool */
private $writable;
/** @var string|null */
private $uri;
private ?int $size = null;
private bool $seekable;
private bool $readable;
private bool $writable;
private ?string $uri = null;
/** @var mixed[] */
private $customMetadata;
private array $customMetadata;
/**
* This constructor accepts an associative array of options.
@@ -53,17 +44,15 @@ class Stream implements StreamInterface
throw new \InvalidArgumentException('Stream must be a resource');
}
if (isset($options['size'])) {
$this->size = $options['size'];
}
$this->size = Integers::assertOptionalNonNegativeSize($options['size'] ?? null, 'Stream size');
$this->customMetadata = $options['metadata'] ?? [];
$this->stream = $stream;
$meta = stream_get_meta_data($this->stream);
$this->seekable = $meta['seekable'];
$this->readable = (bool)preg_match(self::READABLE_MODES, $meta['mode']);
$this->writable = (bool)preg_match(self::WRITABLE_MODES, $meta['mode']);
$this->uri = $this->getMetadata('uri');
$this->readable = self::isReadableMode($meta['mode']);
$this->writable = self::isWritableMode($meta['mode']);
$this->uri = $meta['uri'] ?? null;
}
/**
@@ -76,18 +65,11 @@ class Stream implements StreamInterface
public function __toString(): string
{
try {
if ($this->isSeekable()) {
$this->seek(0);
}
return $this->getContents();
} catch (\Throwable $e) {
if (\PHP_VERSION_ID >= 70400) {
throw $e;
}
trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR);
return '';
if ($this->isSeekable()) {
$this->seek(0);
}
return $this->getContents();
}
public function getContents(): string
@@ -143,12 +125,13 @@ class Stream implements StreamInterface
}
$stats = fstat($this->stream);
if (is_array($stats) && isset($stats['size'])) {
$this->size = $stats['size'];
return $this->size;
if ($stats === false) {
return null;
}
return null;
$this->size = Integers::assertEngineInteger($stats['size'], 'Stream size');
return $this->size;
}
public function isReadable(): bool
@@ -182,12 +165,16 @@ class Stream implements StreamInterface
}
$result = ftell($this->stream);
if ($result === false) {
throw new \RuntimeException('Unable to determine stream position');
}
return $result;
$position = Integers::assertEngineInteger($result, 'Stream position');
if ($position === null) {
throw new \RuntimeException('Unable to determine stream position');
}
return $position;
}
public function rewind(): void
@@ -195,10 +182,8 @@ class Stream implements StreamInterface
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
$whence = (int) $whence;
if (!isset($this->stream)) {
throw new \RuntimeException('Stream is detached');
}
@@ -207,11 +192,11 @@ class Stream implements StreamInterface
}
if (fseek($this->stream, $offset, $whence) === -1) {
throw new \RuntimeException('Unable to seek to stream position '
. $offset . ' with whence ' . var_export($whence, true));
.$offset.' with whence '.var_export($whence, true));
}
}
public function read($length): string
public function read(int $length): string
{
if (!isset($this->stream)) {
throw new \RuntimeException('Stream is detached');
@@ -229,18 +214,32 @@ class Stream implements StreamInterface
try {
$string = fread($this->stream, $length);
} catch (TimeoutException $e) {
throw $e;
} catch (\Exception $e) {
if ($this->timedOut()) {
throw new TimeoutException('Unable to read from stream: timed out', 0, $e);
}
throw new \RuntimeException('Unable to read from stream', 0, $e);
}
if (false === $string) {
if ($this->timedOut()) {
throw new TimeoutException('Unable to read from stream: timed out');
}
throw new \RuntimeException('Unable to read from stream');
}
if ($string === '' && $this->timedOut()) {
throw new TimeoutException('Unable to read from stream: timed out');
}
return $string;
}
public function write($string): int
public function write(string $string): int
{
if (!isset($this->stream)) {
throw new \RuntimeException('Stream is detached');
@@ -249,27 +248,48 @@ class Stream implements StreamInterface
throw new \RuntimeException('Cannot write to a non-writable stream');
}
if ($string === '') {
return 0;
}
// We can't know the size after writing anything
$this->size = null;
$result = fwrite($this->stream, $string);
try {
$result = fwrite($this->stream, $string);
} catch (TimeoutException $e) {
throw $e;
} catch (\Exception $e) {
if ($this->writeTimedOut()) {
throw new TimeoutException('Unable to write to stream: timed out', 0, $e);
}
throw new \RuntimeException('Unable to write to stream', 0, $e);
}
if ($result === false) {
if ($this->writeTimedOut()) {
throw new TimeoutException('Unable to write to stream: timed out');
}
throw new \RuntimeException('Unable to write to stream');
}
if ($result === 0 && $this->writeTimedOut()) {
throw new TimeoutException('Unable to write to stream: timed out');
}
return $result;
}
/**
* {@inheritdoc}
*
* @return mixed
*/
public function getMetadata($key = null)
public function getMetadata(?string $key = null)
{
if (!isset($this->stream)) {
return $key ? null : [];
} elseif (!$key) {
return $key === null ? [] : null;
} elseif ($key === null) {
return $this->customMetadata + stream_get_meta_data($this->stream);
} elseif (isset($this->customMetadata[$key])) {
return $this->customMetadata[$key];
@@ -279,4 +299,36 @@ class Stream implements StreamInterface
return $meta[$key] ?? null;
}
/**
* @see https://www.php.net/manual/en/function.fopen.php
* @see https://www.php.net/manual/en/function.gzopen.php
*/
private static function isReadableMode(string $mode): bool
{
return str_starts_with($mode, 'r') || str_contains($mode, '+');
}
/**
* @see https://www.php.net/manual/en/function.fopen.php
* @see https://www.php.net/manual/en/function.gzopen.php
*/
private static function isWritableMode(string $mode): bool
{
return str_starts_with($mode, 'a')
|| str_starts_with($mode, 'w')
|| str_starts_with($mode, 'x')
|| str_starts_with($mode, 'c')
|| str_contains($mode, '+');
}
private function timedOut(): bool
{
return StreamTimeout::isResourceReadTimedOut($this->stream);
}
private function writeTimedOut(): bool
{
return StreamTimeout::isResourceWriteTimedOut($this->stream);
}
}
@@ -24,33 +24,25 @@ trait StreamDecoratorTrait
/**
* Magic method used to create a new stream if streams are not added in
* the constructor of a decorator (e.g., LazyOpenStream).
*
* @return StreamInterface
*/
public function __get(string $name)
public function __get(string $name): StreamInterface
{
if ($name === 'stream') {
$this->stream = $this->createStream();
return $this->stream;
}
throw new \UnexpectedValueException("$name not found on class");
throw new \UnexpectedValueException(\sprintf('%s not found on class', DiagnosticValue::escape($name)));
}
public function __toString(): string
{
try {
if ($this->isSeekable()) {
$this->seek(0);
}
return $this->getContents();
} catch (\Throwable $e) {
if (\PHP_VERSION_ID >= 70400) {
throw $e;
}
trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR);
return '';
if ($this->isSeekable()) {
$this->seek(0);
}
return $this->getContents();
}
public function getContents(): string
@@ -67,7 +59,7 @@ trait StreamDecoratorTrait
{
/** @var callable $callable */
$callable = [$this->stream, $method];
$result = call_user_func_array($callable, $args);
$result = ($callable)(...$args);
// Always return the wrapped object if the result is a return $this
return $result === $this->stream ? $this : $result;
@@ -79,11 +71,9 @@ trait StreamDecoratorTrait
}
/**
* {@inheritdoc}
*
* @return mixed
*/
public function getMetadata($key = null)
public function getMetadata(?string $key = null)
{
return $this->stream->getMetadata($key);
}
@@ -128,17 +118,21 @@ trait StreamDecoratorTrait
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
$this->stream->seek($offset, $whence);
}
public function read($length): string
public function read(int $length): string
{
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
return $this->stream->read($length);
}
public function write($string): int
public function write(string $string): int
{
return $this->stream->write($string);
}
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use Psr\Http\Message\StreamInterface;
/**
* @internal
*/
final class StreamTimeout
{
private function __construct()
{
}
public static function read(StreamInterface $stream, int $length, string $timeoutMessage): string
{
try {
$buffer = $stream->read($length);
} catch (TimeoutException $e) {
throw $e;
} catch (\RuntimeException $e) {
self::throwIfReadTimedOut($stream, $timeoutMessage, $e);
throw $e;
}
if ($buffer === '') {
self::throwIfReadTimedOut($stream, $timeoutMessage);
}
return $buffer;
}
public static function throwIfReadTimedOut(
StreamInterface $stream,
string $message,
?\Throwable $previous = null
): void {
if (self::isReadTimedOut($stream)) {
throw new TimeoutException($message, 0, $previous);
}
}
public static function throwIfWriteTimedOut(StreamInterface $stream, ?\Throwable $previous = null): void
{
if (self::isWriteTimedOut($stream)) {
throw new TimeoutException('Unable to write to stream: timed out', 0, $previous);
}
}
public static function isReadTimedOut(StreamInterface $stream): bool
{
try {
if ($stream->getMetadata('timed_out') !== true) {
return false;
}
return !$stream->eof();
} catch (\Throwable $e) {
return false;
}
}
public static function isWriteTimedOut(StreamInterface $stream): bool
{
try {
return $stream->getMetadata('timed_out') === true;
} catch (\Throwable $e) {
return false;
}
}
/**
* @param resource $resource
*/
public static function isResourceReadTimedOut($resource): bool
{
try {
/** @var array<string, mixed> $metadata */
$metadata = stream_get_meta_data($resource);
return ($metadata['timed_out'] ?? false) === true && !feof($resource);
} catch (\Throwable $e) {
return false;
}
}
/**
* @param resource $resource
*/
public static function isResourceWriteTimedOut($resource): bool
{
try {
/** @var array<string, mixed> $metadata */
$metadata = stream_get_meta_data($resource);
return ($metadata['timed_out'] ?? false) === true;
} catch (\Throwable $e) {
return false;
}
}
}
+127 -48
View File
@@ -16,11 +16,10 @@ final class StreamWrapper
/** @var resource */
public $context;
/** @var StreamInterface */
private $stream;
private StreamInterface $stream;
/** @var string r, r+, or w */
private $mode;
private string $mode;
/**
* Returns a resource representing the stream.
@@ -41,10 +40,16 @@ final class StreamWrapper
$mode = 'w';
} else {
throw new \InvalidArgumentException('The stream must be readable, '
. 'writable, or both.');
.'writable, or both.');
}
return fopen('guzzle://stream', $mode, false, self::createStreamContext($stream));
$resource = @fopen('guzzle://stream', $mode, false, self::createStreamContext($stream));
if ($resource === false) {
throw new \RuntimeException('Unable to create stream resource');
}
return $resource;
}
/**
@@ -55,7 +60,7 @@ final class StreamWrapper
public static function createStreamContext(StreamInterface $stream)
{
return stream_context_create([
'guzzle' => ['stream' => $stream]
'guzzle' => ['stream' => $stream],
]);
}
@@ -69,43 +74,75 @@ final class StreamWrapper
}
}
public function stream_open(string $path, string $mode, int $options, string &$opened_path = null): bool
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path = null): bool
{
$options = stream_context_get_options($this->context);
$stream = $options['guzzle']['stream'] ?? null;
if (!isset($options['guzzle']['stream'])) {
if (!$stream instanceof StreamInterface) {
return false;
}
$this->mode = $mode;
$this->stream = $options['guzzle']['stream'];
$this->stream = $stream;
return true;
}
public function stream_read(int $count): string
/**
* @return string|false
*/
public function stream_read(int $count)
{
return $this->stream->read($count);
try {
return $this->stream->read($count);
} catch (\RuntimeException $e) {
return false;
}
}
public function stream_write(string $data): int
{
return $this->stream->write($data);
try {
return $this->stream->write($data);
} catch (\RuntimeException $e) {
return -1;
}
}
public function stream_tell(): int
public function stream_flush(): bool
{
return $this->stream->tell();
return true;
}
/**
* @return int|false
*/
public function stream_tell()
{
try {
return $this->stream->tell();
} catch (\RuntimeException $e) {
return false;
}
}
public function stream_eof(): bool
{
return $this->stream->eof();
try {
return $this->stream->eof();
} catch (\RuntimeException $e) {
return true;
}
}
public function stream_seek(int $offset, int $whence): bool
{
$this->stream->seek($offset, $whence);
try {
$this->stream->seek($offset, $whence);
} catch (\RuntimeException $e) {
return false;
}
return true;
}
@@ -115,61 +152,103 @@ final class StreamWrapper
*/
public function stream_cast(int $cast_as)
{
$stream = clone($this->stream);
$resource = $stream->detach();
try {
$stream = clone $this->stream;
$resource = $stream->detach();
} catch (\RuntimeException $e) {
return false;
}
return $resource ?? false;
}
/**
* @return array<int|string, int>
* @return array{
* dev: int,
* ino: int,
* mode: int,
* nlink: int,
* uid: int,
* gid: int,
* rdev: int,
* size: int,
* atime: int,
* mtime: int,
* ctime: int,
* blksize: int,
* blocks: int
* }|false
*/
public function stream_stat(): array
public function stream_stat()
{
try {
$size = $this->stream->getSize();
} catch (\RuntimeException $e) {
return false;
}
if ($size === null) {
return false;
}
static $modeMap = [
'r' => 33060,
'r' => 33060,
'rb' => 33060,
'r+' => 33206,
'w' => 33188,
'wb' => 33188
'w' => 33188,
'wb' => 33188,
];
return [
'dev' => 0,
'ino' => 0,
'mode' => $modeMap[$this->mode],
'nlink' => 0,
'uid' => 0,
'gid' => 0,
'rdev' => 0,
'size' => $this->stream->getSize() ?: 0,
'atime' => 0,
'mtime' => 0,
'ctime' => 0,
'dev' => 0,
'ino' => 0,
'mode' => $modeMap[$this->mode] ?? 0,
'nlink' => 0,
'uid' => 0,
'gid' => 0,
'rdev' => 0,
'size' => $size,
'atime' => 0,
'mtime' => 0,
'ctime' => 0,
'blksize' => 0,
'blocks' => 0
'blocks' => 0,
];
}
/**
* @return array<int|string, int>
* @return array{
* dev: int,
* ino: int,
* mode: int,
* nlink: int,
* uid: int,
* gid: int,
* rdev: int,
* size: int,
* atime: int,
* mtime: int,
* ctime: int,
* blksize: int,
* blocks: int
* }
*/
public function url_stat(string $path, int $flags): array
{
return [
'dev' => 0,
'ino' => 0,
'mode' => 0,
'nlink' => 0,
'uid' => 0,
'gid' => 0,
'rdev' => 0,
'size' => 0,
'atime' => 0,
'mtime' => 0,
'ctime' => 0,
'dev' => 0,
'ino' => 0,
'mode' => 0,
'nlink' => 0,
'uid' => 0,
'gid' => 0,
'rdev' => 0,
'size' => 0,
'atime' => 0,
'mtime' => 0,
'ctime' => 0,
'blksize' => 0,
'blocks' => 0
'blocks' => 0,
];
}
}
+30 -53
View File
@@ -11,51 +11,30 @@ use RuntimeException;
class UploadedFile implements UploadedFileInterface
{
private const ERRORS = [
UPLOAD_ERR_OK,
UPLOAD_ERR_INI_SIZE,
UPLOAD_ERR_FORM_SIZE,
UPLOAD_ERR_PARTIAL,
UPLOAD_ERR_NO_FILE,
UPLOAD_ERR_NO_TMP_DIR,
UPLOAD_ERR_CANT_WRITE,
UPLOAD_ERR_EXTENSION,
private const ERROR_MAP = [
UPLOAD_ERR_OK => 'UPLOAD_ERR_OK',
UPLOAD_ERR_INI_SIZE => 'UPLOAD_ERR_INI_SIZE',
UPLOAD_ERR_FORM_SIZE => 'UPLOAD_ERR_FORM_SIZE',
UPLOAD_ERR_PARTIAL => 'UPLOAD_ERR_PARTIAL',
UPLOAD_ERR_NO_FILE => 'UPLOAD_ERR_NO_FILE',
UPLOAD_ERR_NO_TMP_DIR => 'UPLOAD_ERR_NO_TMP_DIR',
UPLOAD_ERR_CANT_WRITE => 'UPLOAD_ERR_CANT_WRITE',
UPLOAD_ERR_EXTENSION => 'UPLOAD_ERR_EXTENSION',
];
/**
* @var string|null
*/
private $clientFilename;
private ?string $clientFilename;
/**
* @var string|null
*/
private $clientMediaType;
private ?string $clientMediaType;
/**
* @var int
*/
private $error;
private int $error;
/**
* @var string|null
*/
private $file;
private ?string $file = null;
/**
* @var bool
*/
private $moved = false;
private bool $moved = false;
/**
* @var int|null
*/
private $size;
private ?int $size;
/**
* @var StreamInterface|null
*/
private $stream;
private ?StreamInterface $stream = null;
/**
* @param StreamInterface|string|resource $streamOrFile
@@ -64,11 +43,11 @@ class UploadedFile implements UploadedFileInterface
$streamOrFile,
?int $size,
int $errorStatus,
string $clientFilename = null,
string $clientMediaType = null
?string $clientFilename = null,
?string $clientMediaType = null
) {
$this->setError($errorStatus);
$this->size = $size;
$this->size = Integers::assertOptionalNonNegativeSize($size, 'Uploaded file size');
$this->clientFilename = $clientFilename;
$this->clientMediaType = $clientMediaType;
@@ -104,7 +83,7 @@ class UploadedFile implements UploadedFileInterface
*/
private function setError(int $error): void
{
if (false === in_array($error, UploadedFile::ERRORS, true)) {
if (!isset(UploadedFile::ERROR_MAP[$error])) {
throw new InvalidArgumentException(
'Invalid error status for UploadedFile'
);
@@ -113,11 +92,6 @@ class UploadedFile implements UploadedFileInterface
$this->error = $error;
}
private function isStringNotEmpty($param): bool
{
return is_string($param) && false === empty($param);
}
/**
* Return true if there is no upload error
*/
@@ -137,7 +111,7 @@ class UploadedFile implements UploadedFileInterface
private function validateActive(): void
{
if (false === $this->isOk()) {
throw new RuntimeException('Cannot retrieve stream due to upload error');
throw new RuntimeException(\sprintf('Cannot retrieve stream due to upload error (%s)', self::ERROR_MAP[$this->error]));
}
if ($this->isMoved()) {
@@ -159,11 +133,11 @@ class UploadedFile implements UploadedFileInterface
return new LazyOpenStream($file, 'r+');
}
public function moveTo($targetPath): void
public function moveTo(string $targetPath): void
{
$this->validateActive();
if (false === $this->isStringNotEmpty($targetPath)) {
if ($targetPath === '') {
throw new InvalidArgumentException(
'Invalid path provided for move operation; must be a non-empty string'
);
@@ -174,8 +148,13 @@ class UploadedFile implements UploadedFileInterface
? rename($this->file, $targetPath)
: move_uploaded_file($this->file, $targetPath);
} else {
$stream = $this->getStream();
if ($stream->isSeekable()) {
$stream->rewind();
}
Utils::copyToStream(
$this->getStream(),
$stream,
new LazyOpenStream($targetPath, 'w')
);
@@ -183,9 +162,7 @@ class UploadedFile implements UploadedFileInterface
}
if (false === $this->moved) {
throw new RuntimeException(
sprintf('Uploaded file could not be moved to %s', $targetPath)
);
throw new RuntimeException(sprintf('Uploaded file could not be moved to %s', DiagnosticValue::escape($targetPath)));
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use InvalidArgumentException;
use Psr\Http\Message\UploadedFileInterface;
/**
* @internal
*
* @phpstan-type UploadedFileTree array<array-key, UploadedFileInterface|array>
*/
final class UploadedFileNormalizer
{
private function __construct()
{
}
/**
* Return an UploadedFile instance array.
*
* @param array $files An array which respect $_FILES structure
*
* @return UploadedFileTree
*
* @throws InvalidArgumentException for unrecognized values
*/
public static function normalize(array $files): array
{
$normalized = [];
foreach ($files as $key => $value) {
if ($value instanceof UploadedFileInterface) {
$normalized[$key] = $value;
} elseif (is_array($value) && array_key_exists('tmp_name', $value)) {
$normalized[$key] = self::createUploadedFileFromSpec($value);
} elseif (is_array($value)) {
$normalized[$key] = self::normalize($value);
continue;
} else {
throw new InvalidArgumentException('Invalid value in files specification');
}
}
return $normalized;
}
/**
* Create and return an UploadedFile instance from a $_FILES specification.
*
* If the specification represents an array of values, this method will
* delegate to normalizeNestedFileSpec() and return that return value.
*
* @param array $value $_FILES struct
*
* @return UploadedFileInterface|UploadedFileTree
*/
private static function createUploadedFileFromSpec(array $value)
{
self::assertFileSpec($value);
if (is_array($value['tmp_name'])) {
return self::normalizeNestedFileSpec($value);
}
return new UploadedFile(
$value['tmp_name'],
Integers::assertNonNegativeInteger($value['size'], 'Uploaded file size'),
Integers::assertNonNegativeInteger($value['error'], 'Uploaded file error'),
$value['name'] ?? null,
$value['type'] ?? null
);
}
private static function assertFileSpec(array $value): void
{
if (!isset($value['tmp_name'], $value['size'], $value['error'])) {
throw new InvalidArgumentException('Invalid file specification; expected keys "tmp_name", "size", and "error".');
}
}
/**
* Normalize an array of file specifications.
*
* Loops through all nested files and returns a normalized array of
* UploadedFileInterface instances.
*
* @return UploadedFileTree
*/
private static function normalizeNestedFileSpec(array $files = []): array
{
self::assertNestedFileSpec($files);
$normalizedFiles = [];
foreach (array_keys($files['tmp_name']) as $key) {
if (!array_key_exists($key, $files['size']) || !array_key_exists($key, $files['error'])) {
throw new InvalidArgumentException('Invalid nested file specification; expected "tmp_name", "size", and "error" arrays to have matching keys.');
}
$spec = [
'tmp_name' => $files['tmp_name'][$key],
'size' => $files['size'][$key],
'error' => $files['error'][$key],
'name' => $files['name'][$key] ?? null,
'type' => $files['type'][$key] ?? null,
];
$normalizedFiles[$key] = self::createUploadedFileFromSpec($spec);
}
return $normalizedFiles;
}
private static function assertNestedFileSpec(array $files): void
{
foreach (['tmp_name', 'size', 'error'] as $key) {
if (!isset($files[$key]) || !is_array($files[$key])) {
throw new InvalidArgumentException('Invalid nested file specification; expected keys "tmp_name", "size", and "error" to be arrays.');
}
}
foreach (['name', 'type'] as $key) {
if (isset($files[$key]) && !is_array($files[$key])) {
throw new InvalidArgumentException(sprintf('Invalid nested file specification; expected key "%s" to be an array when present.', $key));
}
}
}
}
+337 -220
View File
@@ -17,15 +17,14 @@ use Psr\Http\Message\UriInterface;
class Uri implements UriInterface, \JsonSerializable
{
/**
* Absolute http and https URIs require a host per RFC 7230 Section 2.7
* but in generic URIs the host can be empty. So for http(s) URIs
* we apply this default host when no host is given yet to form a
* valid URI.
* Absolute http and https URIs require a host per RFC 9110 Section 4.2.1
* but in generic URIs the host can be empty. So for http(s) URIs we apply
* this default host when no host is given yet to form a valid URI.
*/
private const HTTP_DEFAULT_HOST = 'localhost';
private const DEFAULT_PORTS = [
'http' => 80,
'http' => 80,
'https' => 443,
'ftp' => 21,
'gopher' => 70,
@@ -36,132 +35,85 @@ class Uri implements UriInterface, \JsonSerializable
'imap' => 143,
'pop' => 110,
'ldap' => 389,
'ws' => 80,
'wss' => 443,
];
/**
* Unreserved characters for use in a regex.
*
* @link https://tools.ietf.org/html/rfc3986#section-2.3
*/
private const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~';
/**
* Sub-delims for use in a regex.
*
* @link https://tools.ietf.org/html/rfc3986#section-2.2
*/
private const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
private const QUERY_SEPARATORS_REPLACEMENT = ['=' => '%3D', '&' => '%26'];
private const QUERY_SEPARATORS_REPLACEMENT = ['=' => '%3D', '&' => '%26', '+' => '%2B'];
/** @var string Uri scheme. */
private $scheme = '';
private string $scheme = '';
/** @var string Uri user info. */
private $userInfo = '';
private string $userInfo = '';
/** @var string Uri host. */
private $host = '';
private string $host = '';
/** @var int|null Uri port. */
private $port;
private ?int $port = null;
/** @var string Uri path. */
private $path = '';
private string $path = '';
/** @var string Uri query string. */
private $query = '';
private string $query = '';
/** @var string Uri fragment. */
private $fragment = '';
private string $fragment = '';
/** @var string|null String representation */
private $composedComponents;
public function __construct(string $uri = '')
{
public function __construct(
#[\SensitiveParameter]
string $uri = ''
) {
if ($uri !== '') {
$parts = self::parse($uri);
$parts = UriParser::parse($uri);
if ($parts === false) {
throw new MalformedUriException("Unable to parse URI: $uri");
throw new MalformedUriException(\sprintf('Unable to parse URI: %s', Utils::redactUriStringForMessage($uri)));
}
try {
$this->applyParts($parts);
} catch (MalformedUriException $e) {
throw $e;
} catch (\InvalidArgumentException $e) {
throw new MalformedUriException($e->getMessage(), 0, $e);
}
$this->applyParts($parts);
}
}
/**
* UTF-8 aware \parse_url() replacement.
*
* The internal function produces broken output for non ASCII domain names
* (IDN) when used with locales other than "C".
*
* On the other hand, cURL understands IDN correctly only when UTF-8 locale
* is configured ("C.UTF-8", "en_US.UTF-8", etc.).
*
* @see https://bugs.php.net/bug.php?id=52923
* @see https://www.php.net/manual/en/function.parse-url.php#114817
* @see https://curl.haxx.se/libcurl/c/CURLOPT_URL.html#ENCODING
*
* @return array|false
*/
private static function parse(string $url)
{
// If IPv6
$prefix = '';
if (preg_match('%^(.*://\[[0-9:a-f]+\])(.*?)$%', $url, $matches)) {
/** @var array{0:string, 1:string, 2:string} $matches */
$prefix = $matches[1];
$url = $matches[2];
}
/** @var string */
$encodedUrl = preg_replace_callback(
'%[^:/@?&=#]+%usD',
static function ($matches) {
return urlencode($matches[0]);
},
$url
);
$result = parse_url($prefix . $encodedUrl);
if ($result === false) {
return false;
}
return array_map('urldecode', $result);
}
public function __toString(): string
{
if ($this->composedComponents === null) {
$this->composedComponents = self::composeComponents(
$this->scheme,
$this->getAuthority(),
$this->path,
$this->query,
$this->fragment
);
}
return $this->composedComponents;
return self::composeComponents(
$this->scheme,
$this->getAuthority(),
$this->path,
$this->query,
$this->fragment
);
}
/**
* Composes a URI reference string from its various components.
* Composes a URI reference string from its various components according to
* RFC 3986 Section 5.3.
*
* Usually this method does not need to be called manually but instead is used indirectly via
* `Psr\Http\Message\UriInterface::__toString`.
* Usually this method does not need to be called manually but instead is
* used indirectly via `Psr\Http\Message\UriInterface::__toString`.
*
* PSR-7 UriInterface treats an empty component the same as a missing component as
* getQuery(), getFragment() etc. always return a string. This explains the slight
* difference to RFC 3986 Section 5.3.
* PSR-7 UriInterface treats an empty component the same as a missing
* component as `getQuery()`, `getFragment()` etc. always return a string.
* This explains the slight difference to RFC 3986 Section 5.3.
*
* Another adjustment is that the authority separator is added even when the authority is missing/empty
* for the "file" scheme. This is because PHP stream functions like `file_get_contents` only work with
* `file:///myfile` but not with `file:/myfile` although they are equivalent according to RFC 3986. But
* `file:///` is the more common syntax for the file scheme anyway (Chrome for example redirects to
* that format).
* Another adjustment is that the authority separator is added even when the
* authority is missing/empty for the "file" scheme. This is because PHP
* stream functions like `file_get_contents` only work with `file:///myfile`
* but not with `file:/myfile` although they are equivalent according to RFC
* 3986. But `file:///` is the more common syntax for the file scheme anyway
* (Chrome for example redirects to that format). The separator is omitted
* when such a URI has a rootless or empty path: adding it would turn the
* first path segment into the authority of the composed URI, or compose the
* string `file://`, which cannot be parsed back into a URI.
*
* @link https://tools.ietf.org/html/rfc3986#section-5.3
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.3
*/
public static function composeComponents(?string $scheme, ?string $authority, string $path, ?string $query, ?string $fragment): string
{
@@ -169,25 +121,25 @@ class Uri implements UriInterface, \JsonSerializable
// weak type checks to also accept null until we can add scalar type hints
if ($scheme != '') {
$uri .= $scheme . ':';
$uri .= $scheme.':';
}
if ($authority != '' || $scheme === 'file') {
$uri .= '//' . $authority;
if ($authority != '' || ($scheme === 'file' && str_starts_with($path, '/'))) {
$uri .= '//'.$authority;
}
if ($authority != '' && $path != '' && $path[0] != '/') {
$path = '/' . $path;
if ($authority != '' && $path != '' && !str_starts_with($path, '/')) {
$path = '/'.$path;
}
$uri .= $path;
if ($query != '') {
$uri .= '?' . $query;
$uri .= '?'.$query;
}
if ($fragment != '') {
$uri .= '#' . $fragment;
$uri .= '#'.$fragment;
}
return $uri;
@@ -196,8 +148,8 @@ class Uri implements UriInterface, \JsonSerializable
/**
* Whether the URI has the default port of the current scheme.
*
* `Psr\Http\Message\UriInterface::getPort` may return null or the standard port. This method can be used
* independently of the implementation.
* `Psr\Http\Message\UriInterface::getPort` may return null or the standard
* port. This method can be used independently of the implementation.
*/
public static function isDefaultPort(UriInterface $uri): bool
{
@@ -208,17 +160,18 @@ class Uri implements UriInterface, \JsonSerializable
/**
* Whether the URI is absolute, i.e. it has a scheme.
*
* An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true
* if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative
* to another URI, the base URI. Relative references can be divided into several forms:
* - network-path references, e.g. '//example.com/path'
* - absolute-path references, e.g. '/path'
* - relative-path references, e.g. 'subpath'
* An instance of UriInterface can either be an absolute URI or a relative
* reference. An absolute URI has a scheme. A relative reference is used to
* express a URI relative to another URI, the base URI. Relative references
* can be divided into several forms according to RFC 3986 Section 4.2:
* - network-path references, e.g. `//example.com/path`
* - absolute-path references, e.g. `/path`
* - relative-path references, e.g. `subpath`
*
* @see Uri::isNetworkPathReference
* @see Uri::isAbsolutePathReference
* @see Uri::isRelativePathReference
* @link https://tools.ietf.org/html/rfc3986#section-4
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
*/
public static function isAbsolute(UriInterface $uri): bool
{
@@ -228,9 +181,10 @@ class Uri implements UriInterface, \JsonSerializable
/**
* Whether the URI is a network-path reference.
*
* A relative reference that begins with two slash characters is termed an network-path reference.
* A relative reference that begins with two slash characters is termed a
* network-path reference.
*
* @link https://tools.ietf.org/html/rfc3986#section-4.2
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
*/
public static function isNetworkPathReference(UriInterface $uri): bool
{
@@ -238,11 +192,12 @@ class Uri implements UriInterface, \JsonSerializable
}
/**
* Whether the URI is a absolute-path reference.
* Whether the URI is an absolute-path reference.
*
* A relative reference that begins with a single slash character is termed an absolute-path reference.
* A relative reference that begins with a single slash character is termed
* an absolute-path reference.
*
* @link https://tools.ietf.org/html/rfc3986#section-4.2
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
*/
public static function isAbsolutePathReference(UriInterface $uri): bool
{
@@ -255,9 +210,10 @@ class Uri implements UriInterface, \JsonSerializable
/**
* Whether the URI is a relative-path reference.
*
* A relative reference that does not begin with a slash character is termed a relative-path reference.
* A relative reference that does not begin with a slash character is termed
* a relative-path reference.
*
* @link https://tools.ietf.org/html/rfc3986#section-4.2
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
*/
public static function isRelativePathReference(UriInterface $uri): bool
{
@@ -269,23 +225,24 @@ class Uri implements UriInterface, \JsonSerializable
/**
* Whether the URI is a same-document reference.
*
* A same-document reference refers to a URI that is, aside from its fragment
* component, identical to the base URI. When no base URI is given, only an empty
* URI reference (apart from its fragment) is considered a same-document reference.
* A same-document reference refers to a URI that is, aside from its
* fragment component, identical to the base URI. When no base URI is given,
* only an empty URI reference (apart from its fragment) is considered a
* same-document reference.
*
* @param UriInterface $uri The URI to check
* @param UriInterface|null $base An optional base URI to compare against
*
* @link https://tools.ietf.org/html/rfc3986#section-4.4
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.4
*/
public static function isSameDocumentReference(UriInterface $uri, UriInterface $base = null): bool
public static function isSameDocumentReference(UriInterface $uri, ?UriInterface $base = null): bool
{
if ($base !== null) {
$uri = UriResolver::resolve($base, $uri);
return ($uri->getScheme() === $base->getScheme())
&& ($uri->getAuthority() === $base->getAuthority())
&& ($uri->getPath() === $base->getPath())
&& (self::rawPath($uri) === self::rawPath($base))
&& ($uri->getQuery() === $base->getQuery());
}
@@ -312,10 +269,9 @@ class Uri implements UriInterface, \JsonSerializable
* Creates a new URI with a specific query string value.
*
* Any existing query string values that exactly match the provided key are
* removed and replaced with the given key value pair.
*
* A value of null will set the query string key without a value, e.g. "key"
* instead of "key=value".
* removed and replaced with the given key value pair. A value of null will
* set the query string key without a value, e.g. "key" instead of
* "key=value".
*
* @param UriInterface $uri URI to use as a base.
* @param string $key Key to set.
@@ -331,40 +287,75 @@ class Uri implements UriInterface, \JsonSerializable
}
/**
* Creates a new URI with multiple specific query string values.
* Creates a new URI with multiple query string values.
*
* It has the same behavior as withQueryValue() but for an associative array of key => value.
* It has the same behavior as `withQueryValue()` but for an associative
* array of key => value.
*
* @param UriInterface $uri URI to use as a base.
* @param array<string, string|null> $keyValueArray Associative array of key and values
* @param UriInterface $uri URI to use as a base.
* @param (string|null)[] $keyValueArray Associative array of key and values
*/
public static function withQueryValues(UriInterface $uri, array $keyValueArray): UriInterface
{
$result = self::getFilteredQueryString($uri, array_keys($keyValueArray));
foreach ($keyValueArray as $key => $value) {
self::assertStringOrNullQueryValue($value);
$result[] = self::generateQueryString((string) $key, $value !== null ? (string) $value : null);
}
return $uri->withQuery(implode('&', $result));
}
/**
* @param mixed $value
*/
private static function assertStringOrNullQueryValue($value): void
{
if ($value !== null && !is_string($value)) {
throw new \InvalidArgumentException(\sprintf(
'Query string values must be a string or null, %s given.',
\get_debug_type($value)
));
}
}
/**
* Creates a URI from a hash of `parse_url` components.
*
* @link http://php.net/manual/en/function.parse-url.php
* @see https://www.php.net/manual/en/function.parse-url.php
*
* @throws MalformedUriException If the components do not form a valid URI.
*/
public static function fromParts(array $parts): UriInterface
{
public static function fromParts(
#[\SensitiveParameter]
array $parts
): UriInterface {
$uri = new self();
$uri->applyParts($parts);
$uri->validateState();
try {
$uri->applyParts($parts);
$uri->validateState();
} catch (MalformedUriException $e) {
throw $e;
} catch (\InvalidArgumentException $e) {
throw new MalformedUriException($e->getMessage(), 0, $e);
}
return $uri;
}
/**
* @throws \InvalidArgumentException If the host is invalid.
*
* @internal
*/
public static function assertValidHost(string $host): void
{
if (!Rfc3986::isValidHost($host)) {
throw new \InvalidArgumentException(sprintf('Invalid host: %s', DiagnosticValue::escape($host)));
}
}
public function getScheme(): string
{
return $this->scheme;
@@ -374,11 +365,11 @@ class Uri implements UriInterface, \JsonSerializable
{
$authority = $this->host;
if ($this->userInfo !== '') {
$authority = $this->userInfo . '@' . $authority;
$authority = $this->userInfo.'@'.$authority;
}
if ($this->port !== null) {
$authority .= ':' . $this->port;
$authority .= ':'.$this->port;
}
return $authority;
@@ -401,9 +392,58 @@ class Uri implements UriInterface, \JsonSerializable
public function getPath(): string
{
if (str_starts_with($this->path, '//')) {
return '/'.ltrim($this->path, '/');
}
return $this->path;
}
/**
* Returns the path as it appears within a URI's string form.
*
* getPath() collapses multiple leading slashes so that a path used in
* isolation cannot be mistaken for a protocol-relative URL. Whole-URI
* operations like reference resolution and normalization (RFC 3986
* Sections 5 and 6) are defined on the URI string form, where the path
* stays verbatim, so they must read the path through this method instead.
* For direct instances of this class the path is derived from the stored
* components, including the leading slash the string form adds to a
* rootless path when an authority is present; for subclasses and other
* implementations the path is split from the string form per RFC 3986
* Appendix B, without validating or decoding any other component. The
* scheme is only split off when the instance reports one, as a relative
* reference can begin with a segment containing a colon that the Appendix
* B expression would otherwise read as a scheme.
*
* @throws \RuntimeException If the path cannot be split from the string form.
*
* @internal
*/
public static function rawPath(UriInterface $uri): string
{
if (get_class($uri) === self::class) {
if ($uri->path !== '' && !str_starts_with($uri->path, '/') && $uri->getAuthority() !== '') {
// composeComponents() prepends a slash to a rootless path when
// an authority is present, so the string form uses this path.
return '/'.$uri->path;
}
return $uri->path;
}
$pattern = $uri->getScheme() === ''
? '%^(?://[^/?#]*)?([^?#]*)%'
: '%^(?:[^:/?#]+:)?(?://[^/?#]*)?([^?#]*)%';
$count = preg_match($pattern, (string) $uri, $matches);
if ($count === false) {
throw new \RuntimeException('Unable to read the URI path: '.preg_last_error_msg());
}
return $matches[1] ?? '';
}
public function getQuery(): string
{
return $this->query;
@@ -414,7 +454,7 @@ class Uri implements UriInterface, \JsonSerializable
return $this->fragment;
}
public function withScheme($scheme): UriInterface
public function withScheme(string $scheme): UriInterface
{
$scheme = $this->filterScheme($scheme);
@@ -424,18 +464,20 @@ class Uri implements UriInterface, \JsonSerializable
$new = clone $this;
$new->scheme = $scheme;
$new->composedComponents = null;
$new->removeDefaultPort();
$new->validateState();
return $new;
}
public function withUserInfo($user, $password = null): UriInterface
{
public function withUserInfo(
string $user,
#[\SensitiveParameter]
?string $password = null
): UriInterface {
$info = $this->filterUserInfoComponent($user);
if ($password !== null) {
$info .= ':' . $this->filterUserInfoComponent($password);
$info .= ':'.$this->filterUserInfoComponent($password);
}
if ($this->userInfo === $info) {
@@ -444,13 +486,12 @@ class Uri implements UriInterface, \JsonSerializable
$new = clone $this;
$new->userInfo = $info;
$new->composedComponents = null;
$new->validateState();
return $new;
}
public function withHost($host): UriInterface
public function withHost(string $host): UriInterface
{
$host = $this->filterHost($host);
@@ -460,13 +501,12 @@ class Uri implements UriInterface, \JsonSerializable
$new = clone $this;
$new->host = $host;
$new->composedComponents = null;
$new->validateState();
return $new;
}
public function withPort($port): UriInterface
public function withPort(?int $port): UriInterface
{
$port = $this->filterPort($port);
@@ -476,14 +516,13 @@ class Uri implements UriInterface, \JsonSerializable
$new = clone $this;
$new->port = $port;
$new->composedComponents = null;
$new->removeDefaultPort();
$new->validateState();
return $new;
}
public function withPath($path): UriInterface
public function withPath(string $path): UriInterface
{
$path = $this->filterPath($path);
@@ -493,13 +532,12 @@ class Uri implements UriInterface, \JsonSerializable
$new = clone $this;
$new->path = $path;
$new->composedComponents = null;
$new->validateState();
return $new;
}
public function withQuery($query): UriInterface
public function withQuery(string $query): UriInterface
{
$query = $this->filterQueryAndFragment($query);
@@ -509,12 +547,11 @@ class Uri implements UriInterface, \JsonSerializable
$new = clone $this;
$new->query = $query;
$new->composedComponents = null;
return $new;
}
public function withFragment($fragment): UriInterface
public function withFragment(string $fragment): UriInterface
{
$fragment = $this->filterQueryAndFragment($fragment);
@@ -524,7 +561,6 @@ class Uri implements UriInterface, \JsonSerializable
$new = clone $this;
$new->fragment = $fragment;
$new->composedComponents = null;
return $new;
}
@@ -539,8 +575,10 @@ class Uri implements UriInterface, \JsonSerializable
*
* @param array $parts Array of parse_url parts to apply.
*/
private function applyParts(array $parts): void
{
private function applyParts(
#[\SensitiveParameter]
array $parts
): void {
$this->scheme = isset($parts['scheme'])
? $this->filterScheme($parts['scheme'])
: '';
@@ -551,7 +589,7 @@ class Uri implements UriInterface, \JsonSerializable
? $this->filterHost($parts['host'])
: '';
$this->port = isset($parts['port'])
? $this->filterPort($parts['port'])
? $this->filterPortPart($parts['port'])
: null;
$this->path = isset($parts['path'])
? $this->filterPath($parts['path'])
@@ -563,71 +601,78 @@ class Uri implements UriInterface, \JsonSerializable
? $this->filterQueryAndFragment($parts['fragment'])
: '';
if (isset($parts['pass'])) {
$this->userInfo .= ':' . $this->filterUserInfoComponent($parts['pass']);
$this->userInfo .= ':'.$this->filterUserInfoComponent($parts['pass']);
}
$this->removeDefaultPort();
}
/**
* @param mixed $scheme
*
* @throws \InvalidArgumentException If the scheme is invalid.
*/
private function filterScheme($scheme): string
private function filterScheme(string $scheme): string
{
if (!is_string($scheme)) {
throw new \InvalidArgumentException('Scheme must be a string');
$scheme = Utils::asciiToLower($scheme);
if (!Rfc3986::isValidScheme($scheme)) {
throw new \InvalidArgumentException(sprintf('Invalid scheme: %s', DiagnosticValue::escape($scheme)));
}
return \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
return $scheme;
}
/**
* @param mixed $component
*
* @throws \InvalidArgumentException If the user info is invalid.
*/
private function filterUserInfoComponent($component): string
{
if (!is_string($component)) {
throw new \InvalidArgumentException('User info must be a string');
}
return preg_replace_callback(
'/(?:[^%' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/',
[$this, 'rawurlencodeMatchZero'],
$component
private function filterUserInfoComponent(
#[\SensitiveParameter]
string $component
): string {
return $this->filterComponent(
'/(?:[^%'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.']++|%(?!'.Rfc3986::HEX_OCTET.'))/',
$component,
'Unable to filter URI user info'
);
}
/**
* @param mixed $host
*
* @throws \InvalidArgumentException If the host is invalid.
*/
private function filterHost($host): string
private function filterHost(string $host): string
{
if (!is_string($host)) {
throw new \InvalidArgumentException('Host must be a string');
$host = Utils::asciiToLower($host);
$filtered = \preg_replace_callback('/%'.Rfc3986::HEX_OCTET.'/', static function (array $m): string {
return Utils::asciiToUpper($m[0]);
}, $host);
if ($filtered === null) {
throw new \RuntimeException('Unable to normalize URI host percent-encoding: '.\preg_last_error_msg());
}
self::assertValidHost($filtered);
if (str_starts_with($filtered, '[') && !str_starts_with($filtered, '[v')) {
// assertValidHost() accepted this bracketed value with the same
// filter_var() predicate tryCanonicalizeIpv6() validates with, and
// its pure-PHP parse cannot fail on filter-accepted text, so the
// null guard is defense in depth only.
$canonical = Rfc3986::tryCanonicalizeIpv6(substr($filtered, 1, -1));
if ($canonical !== null) {
$filtered = '['.$canonical.']';
}
}
return \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
return $filtered;
}
/**
* @param mixed $port
*
* @throws \InvalidArgumentException If the port is invalid.
*/
private function filterPort($port): ?int
private function filterPort(?int $port): ?int
{
if ($port === null) {
return null;
}
$port = (int) $port;
if (0 > $port || 0xffff < $port) {
if (0 > $port || 0xFFFF < $port) {
throw new \InvalidArgumentException(
sprintf('Invalid port: %d. Must be between 0 and 65535', $port)
);
@@ -637,7 +682,73 @@ class Uri implements UriInterface, \JsonSerializable
}
/**
* @param string[] $keys
* @param mixed $port
*
* @throws \InvalidArgumentException If the port is invalid.
*/
private function filterPortPart($port): ?int
{
if (\is_int($port)) {
return $this->filterPort($port);
}
if (\is_string($port) && \ctype_digit($port)) {
// A zero port is accepted here; only Rfc9112::parsePort() rejects
// it for HTTP Host/authority parsing.
if (Rfc3986::isValidPort($port)) {
return (int) \ltrim($port, '0');
}
throw new \InvalidArgumentException(sprintf(
'Invalid port: %s. Must be between 0 and 65535',
\ltrim($port, '0')
));
}
throw new \InvalidArgumentException(sprintf(
'Invalid port: %s. Must be between 0 and 65535',
self::describeInvalidPort($port)
));
}
/**
* @param mixed $port
*/
private static function describeInvalidPort($port): string
{
if (\is_string($port)) {
return DiagnosticValue::escape($port);
}
if (\is_int($port)) {
return (string) $port;
}
if (\is_bool($port)) {
return $port ? 'true' : 'false';
}
if ($port === null) {
return 'null';
}
if (\is_float($port)) {
if (\is_nan($port)) {
return 'NAN';
}
if (\is_infinite($port)) {
return $port > 0 ? 'INF' : '-INF';
}
return \sprintf('%.14G', $port);
}
return \get_debug_type($port);
}
/**
* @param (string|int)[] $keys
*
* @return string[]
*/
@@ -649,22 +760,25 @@ class Uri implements UriInterface, \JsonSerializable
return [];
}
$decodedKeys = array_map('rawurldecode', $keys);
$decodedKeys = array_map(function ($k): string {
return rawurldecode((string) $k);
}, $keys);
return array_filter(explode('&', $current), function ($part) use ($decodedKeys) {
return array_filter(explode('&', $current), static function (string $part) use ($decodedKeys): bool {
return !in_array(rawurldecode(explode('=', $part)[0]), $decodedKeys, true);
});
}
private static function generateQueryString(string $key, ?string $value): string
{
// Query string separators ("=", "&") within the key or value need to be encoded
// Query string separators ("=", "&") and literal plus signs ("+") within the
// key or value need to be encoded
// (while preventing double-encoding) before setting the query string. All other
// chars that need percent-encoding will be encoded by withQuery().
$queryString = strtr($key, self::QUERY_SEPARATORS_REPLACEMENT);
if ($value !== null) {
$queryString .= '=' . strtr($value, self::QUERY_SEPARATORS_REPLACEMENT);
$queryString .= '='.strtr($value, self::QUERY_SEPARATORS_REPLACEMENT);
}
return $queryString;
@@ -680,41 +794,44 @@ class Uri implements UriInterface, \JsonSerializable
/**
* Filters the path of a URI
*
* @param mixed $path
*
* @throws \InvalidArgumentException If the path is invalid.
*/
private function filterPath($path): string
private function filterPath(string $path): string
{
if (!is_string($path)) {
throw new \InvalidArgumentException('Path must be a string');
}
return preg_replace_callback(
'/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/]++|%(?![A-Fa-f0-9]{2}))/',
[$this, 'rawurlencodeMatchZero'],
$path
return $this->filterComponent(
'/(?:[^'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.'%:@\/]++|%(?!'.Rfc3986::HEX_OCTET.'))/',
$path,
'Unable to filter URI path'
);
}
/**
* Filters the query string or fragment of a URI.
*
* @param mixed $str
*
* @throws \InvalidArgumentException If the query or fragment is invalid.
*/
private function filterQueryAndFragment($str): string
private function filterQueryAndFragment(string $str): string
{
if (!is_string($str)) {
throw new \InvalidArgumentException('Query and fragment must be a string');
return $this->filterComponent(
'/(?:[^'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.'%:@\/\?]++|%(?!'.Rfc3986::HEX_OCTET.'))/',
$str,
'Unable to filter URI query or fragment'
);
}
private function filterComponent(
string $pattern,
#[\SensitiveParameter]
string $component,
string $context
): string {
$filtered = preg_replace_callback($pattern, [$this, 'rawurlencodeMatchZero'], $component);
if ($filtered === null) {
throw new \RuntimeException($context.': '.preg_last_error_msg());
}
return preg_replace_callback(
'/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/',
[$this, 'rawurlencodeMatchZero'],
$str
);
return $filtered;
}
private function rawurlencodeMatchZero(array $match): string
@@ -729,10 +846,10 @@ class Uri implements UriInterface, \JsonSerializable
}
if ($this->getAuthority() === '') {
if (0 === strpos($this->path, '//')) {
if (str_starts_with($this->path, '//')) {
throw new MalformedUriException('The path of a URI without an authority must not start with two slashes "//"');
}
if ($this->scheme === '' && false !== strpos(explode('/', $this->path, 2)[0], ':')) {
if ($this->scheme === '' && str_contains(explode('/', $this->path, 2)[0], ':')) {
throw new MalformedUriException('A relative URI must not have a path beginning with a segment containing a colon');
}
}
@@ -7,19 +7,33 @@ namespace GuzzleHttp\Psr7;
use Psr\Http\Message\UriInterface;
/**
* Provides methods to determine if a modified URL should be considered cross-origin.
* Provides methods to determine if a modified URI should be considered
* cross-origin.
*
* @author Graham Campbell
*/
final class UriComparator
{
/**
* Determines if a modified URL should be considered cross-origin with
* respect to an original URL.
* Determines if a modified URI should be considered cross-origin with
* respect to an original URI.
*
* Two URIs are cross-origin when their scheme, host, or effective port
* differ. Host comparison is case-insensitive, and bracketed IPv6 literals
* are canonicalized to their RFC 5952 form from any PSR-7 implementation
* before comparison, so equivalent spellings of the same address are
* same-origin. IPvFuture literals and bracketed values that cannot be
* parsed as an IPv6 address, such as those carrying zone identifiers,
* still compare as case-insensitive text. Missing ports use the default
* port for `http`, `https`, `ws`, or `wss`. Other schemes do not receive
* implicit default ports.
*
* This helper only compares URI origins. It does not implement redirect
* handling or credential policy.
*/
public static function isCrossOrigin(UriInterface $original, UriInterface $modified): bool
{
if (\strcasecmp($original->getHost(), $modified->getHost()) !== 0) {
if (!Utils::caselessEquals(self::normalizeHost($original), self::normalizeHost($modified))) {
return true;
}
@@ -34,7 +48,29 @@ final class UriComparator
return false;
}
private static function computePort(UriInterface $uri): int
private static function normalizeHost(UriInterface $uri): string
{
$host = $uri->getHost();
if (!str_starts_with($host, '[') || !str_ends_with($host, ']')) {
return $host;
}
// Foreign UriInterface implementations may carry non-canonical IPv6
// spellings; canonicalize what is unambiguously an IPv6 address so
// equivalent literals compare as same-origin, and leave IPvFuture,
// zone-identifier, and invalid text to the caseless textual
// comparison. Validation is platform-independent, so a spelling only
// some OS parsers accept, such as zero-padded dotted octets, is
// cross-origin everywhere instead of same-origin on some systems.
$canonical = Rfc3986::tryCanonicalizeIpv6(substr($host, 1, -1));
if ($canonical === null) {
return $host;
}
return '['.$canonical.']';
}
private static function computePort(UriInterface $uri): ?int
{
$port = $uri->getPort();
@@ -42,7 +78,15 @@ final class UriComparator
return $port;
}
return 'https' === $uri->getScheme() ? 443 : 80;
if (\in_array($uri->getScheme(), ['http', 'ws'], true)) {
return 80;
}
if (\in_array($uri->getScheme(), ['https', 'wss'], true)) {
return 443;
}
return null;
}
private function __construct()
+290 -54
View File
@@ -11,12 +11,13 @@ use Psr\Http\Message\UriInterface;
*
* @author Tobias Schultze
*
* @link https://tools.ietf.org/html/rfc3986#section-6
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-6
*/
final class UriNormalizer
{
/**
* Default normalizations which only include the ones that preserve semantics.
* Default normalizations which only include the ones that preserve
* semantics.
*/
public const PRESERVING_NORMALIZATIONS =
self::CAPITALIZE_PERCENT_ENCODING |
@@ -24,10 +25,21 @@ final class UriNormalizer
self::CONVERT_EMPTY_PATH |
self::REMOVE_DEFAULT_HOST |
self::REMOVE_DEFAULT_PORT |
self::REMOVE_DOT_SEGMENTS;
self::REMOVE_DOT_SEGMENTS |
self::CANONICALIZE_IPV6_HOST;
/**
* All letters within a percent-encoding triplet (e.g., "%3A") are case-insensitive, and should be capitalized.
* All letters within a percent-encoding triplet (e.g., "%3A") are
* case-insensitive, and should be capitalized. This applies to the
* userinfo, host, path, query, and fragment components. Bracketed
* IP-literal hosts are skipped as a legacy tolerance for nonstandard values
* other implementations may carry; zone-identifier text was briefly valid
* URI syntax under RFC 6874, which RFC 9844 obsoleted and reverted. The
* userinfo and host are only rewritten when the value returned by the
* implementation matches the normalized form, and a userinfo with an empty
* user segment is never rewritten. No percent-encoding normalization is
* applied to a component that contains malformed percent syntax, such as a
* `%` not followed by two hexadecimal digits.
*
* Example: http://example.org/a%c2%b1b → http://example.org/a%C2%B1b
*/
@@ -36,9 +48,22 @@ final class UriNormalizer
/**
* Decodes percent-encoded octets of unreserved characters.
*
* For consistency, percent-encoded octets in the ranges of ALPHA (%41–%5A and %61–%7A), DIGIT (%30–%39),
* hyphen (%2D), period (%2E), underscore (%5F), or tilde (%7E) should not be created by URI producers and,
* when found in a URI, should be decoded to their corresponding unreserved characters by URI normalizers.
* For consistency, percent-encoded octets in the ranges of ALPHA (%41–%5A
* and %61–%7A), DIGIT (%30–%39), hyphen (%2D), period (%2E), underscore
* (%5F), or tilde (%7E) should not be created by URI producers and, when
* found in a URI, should be decoded to their corresponding unreserved
* characters by URI normalizers. This applies to the userinfo, host, path,
* query, and fragment components. Since the host is case-insensitive and
* PSR-7 requires it to be lowercase, octets decoded in the host are
* lowercased (e.g., "%41" becomes "a"). Bracketed IP-literal hosts are
* skipped as a legacy tolerance for nonstandard values other
* implementations may carry; zone-identifier text was briefly valid URI
* syntax under RFC 6874, which RFC 9844 obsoleted and reverted. The
* userinfo and host are only rewritten when the value returned by the
* implementation matches the normalized form, and a userinfo with an empty
* user segment is never rewritten. No percent-encoding normalization is
* applied to a component that contains malformed percent syntax, such as a
* `%` not followed by two hexadecimal digits.
*
* Example: http://example.org/%7Eusern%61me/ → http://example.org/~username/
*/
@@ -54,13 +79,18 @@ final class UriNormalizer
/**
* Removes the default host of the given URI scheme from the URI.
*
* Only the "file" scheme defines the default host "localhost".
* All of `file:/myfile`, `file:///myfile`, and `file://localhost/myfile`
* are equivalent according to RFC 3986. The first format is not accepted
* by PHPs stream functions and thus already normalized implicitly to the
* second format in the Uri class. See `GuzzleHttp\Psr7\Uri::composeComponents`.
* Only the "file" scheme defines the default host "localhost". All of
* `file:/myfile`, `file:///myfile`, and `file://localhost/myfile` are
* equivalent according to RFC 3986. The first format is not accepted by
* PHPs stream functions and thus already normalized implicitly to the
* second format in the Uri class. See
* `GuzzleHttp\Psr7\Uri::composeComponents`.
*
* When removing the host leaves a URI without an authority whose path
* begins with `//`, the path is serialized with a `/.` prefix.
*
* Example: file://localhost/myfile → file:///myfile
* Example: file://localhost//x → file:///.//x
*/
public const REMOVE_DEFAULT_HOST = 8;
@@ -84,9 +114,10 @@ final class UriNormalizer
/**
* Paths which include two or more adjacent slashes are converted to one.
*
* Webservers usually ignore duplicate slashes and treat those URIs equivalent.
* But in theory those URIs do not need to be equivalent. So this normalization
* may change the semantics. Encoded slashes (%2F) are not removed.
* Webservers usually ignore duplicate slashes and treat those URIs
* equivalent. But in theory those URIs do not need to be equivalent. So
* this normalization may change the semantics. Encoded slashes (%2F) are
* not removed.
*
* Example: http://example.org//foo///bar.html → http://example.org/foo/bar.html
*/
@@ -95,31 +126,61 @@ final class UriNormalizer
/**
* Sort query parameters with their values in alphabetical order.
*
* However, the order of parameters in a URI may be significant (this is not defined by the standard).
* So this normalization is not safe and may change the semantics of the URI.
* However, the order of parameters in a URI may be significant (this is not
* defined by the standard). So this normalization is not safe and may
* change the semantics of the URI.
*
* Example: ?lang=en&article=fred → ?article=fred&lang=en
*
* Note: The sorting is neither locale nor Unicode aware (the URI query does not get decoded at all) as the
* purpose is to be able to compare URIs in a reproducible way, not to have the params sorted perfectly.
* Note: The sorting is neither locale nor Unicode aware (the URI query does
* not get decoded at all) as the purpose is to be able to compare URIs in a
* reproducible way, not to have the params sorted perfectly.
*/
public const SORT_QUERY_PARAMETERS = 128;
/**
* Canonicalizes IPv6 hosts to their RFC 5952 form.
*
* IPv6 addresses allow leading zeros and multiple placements of the `::`
* elision, so the same address has many textual spellings. The canonical
* form is required for IPv6 literals in URIs by RFC 5952 Section 6 and
* never changes what the URI refers to. Native `Uri` instances already
* guarantee canonical output; for other implementations, the canonical
* host is requested through `withHost()` and the result is kept only when
* the returned `getHost()` exactly matches the requested spelling,
* otherwise this step leaves the URI unchanged while other selected
* normalizations still apply, and setter exceptions propagate.
*
* Example: http://[::0:0a]/ → http://[::a]/
*/
public const CANONICALIZE_IPV6_HOST = 256;
/**
* Returns a normalized URI.
*
* The scheme and host component are already normalized to lowercase per PSR-7 UriInterface.
* This methods adds additional normalizations that can be configured with the $flags parameter.
* The scheme and host component are already normalized to lowercase per
* PSR-7 UriInterface. This method adds additional normalizations that can
* be configured with the `$flags` parameter, which is a bitmask of
* normalizations to apply.
*
* PSR-7 UriInterface cannot distinguish between an empty component and a missing component as
* getQuery(), getFragment() etc. always return a string. This means the URIs "/?#" and "/" are
* treated equivalent which is not necessarily true according to RFC 3986. But that difference
* is highly uncommon in reality. So this potential normalization is implied in PSR-7 as well.
* PSR-7 UriInterface cannot distinguish between an empty component and a
* missing component as `getQuery()`, `getFragment()` etc. always return a
* string. This means the URIs `/?#` and `/` are treated equivalent which is
* not necessarily true according to RFC 3986. But that difference is highly
* uncommon in reality. So this potential normalization is implied in PSR-7
* as well.
*
* A path the URI cannot hold, such as a `//`-leading path without an
* authority or a relative-path reference whose first segment contains a
* colon, is prefixed with `/.` or `./` respectively instead of throwing, as
* `UriResolver::resolve()` does. The percent-encoding normalizations only
* do so where they rewrote the path. For example, decoding `a%41:` yields
* `./aA:`, since `aA:` would be an absolute URI with the scheme `aa`.
*
* @param UriInterface $uri The URI to normalize
* @param int $flags A bitmask of normalizations to apply, see constants
*
* @link https://tools.ietf.org/html/rfc3986#section-6.2
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-6.2
*/
public static function normalize(UriInterface $uri, int $flags = self::PRESERVING_NORMALIZATIONS): UriInterface
{
@@ -131,13 +192,21 @@ final class UriNormalizer
$uri = self::decodeUnreservedCharacters($uri);
}
if ($flags & self::CONVERT_EMPTY_PATH && $uri->getPath() === '' &&
($uri->getScheme() === 'http' || $uri->getScheme() === 'https')
if ($flags & self::CONVERT_EMPTY_PATH && $uri->getPath() === ''
&& ($uri->getScheme() === 'http' || $uri->getScheme() === 'https')
) {
$uri = $uri->withPath('/');
}
if ($flags & self::REMOVE_DEFAULT_HOST && $uri->getScheme() === 'file' && $uri->getHost() === 'localhost') {
if ($uri->getUserInfo() === '' && $uri->getPort() === null) {
$path = Uri::rawPath($uri);
if (str_starts_with($path, '//')) {
// "/." keeps a "//" path unambiguous once the authority is gone
$uri = $uri->withPath('/.'.$path);
}
}
$uri = $uri->withHost('');
}
@@ -145,12 +214,24 @@ final class UriNormalizer
$uri = $uri->withPort(null);
}
if ($flags & self::REMOVE_DOT_SEGMENTS && !Uri::isRelativePathReference($uri)) {
$uri = $uri->withPath(UriResolver::removeDotSegments($uri->getPath()));
}
$removeDotSegments = ($flags & self::REMOVE_DOT_SEGMENTS) && !Uri::isRelativePathReference($uri);
if ($flags & self::REMOVE_DUPLICATE_SLASHES) {
$uri = $uri->withPath(preg_replace('#//++#', '/', $uri->getPath()));
if ($removeDotSegments || $flags & self::REMOVE_DUPLICATE_SLASHES) {
$path = Uri::rawPath($uri);
if ($removeDotSegments) {
$path = UriResolver::removeDotSegments($path);
}
if ($flags & self::REMOVE_DUPLICATE_SLASHES) {
$path = preg_replace('#//++#', '/', $path);
if ($path === null) {
throw new \RuntimeException('Unable to remove duplicate slashes from URI path: '.preg_last_error_msg());
}
}
$uri = $uri->withPath(UriResolver::guardedPath($uri, $path));
}
if ($flags & self::SORT_QUERY_PARAMETERS && $uri->getQuery() !== '') {
@@ -159,22 +240,28 @@ final class UriNormalizer
$uri = $uri->withQuery(implode('&', $queryKeyValues));
}
if ($flags & self::CANONICALIZE_IPV6_HOST) {
$uri = self::canonicalizeIpv6Host($uri);
}
return $uri;
}
/**
* Whether two URIs can be considered equivalent.
*
* Both URIs are normalized automatically before comparison with the given $normalizations bitmask. The method also
* accepts relative URI references and returns true when they are equivalent. This of course assumes they will be
* resolved against the same base URI. If this is not the case, determination of equivalence or difference of
* relative references does not mean anything.
* Both URIs are normalized automatically before comparison with the given
* `$normalizations` bitmask. The method also accepts relative URI
* references and returns true when they are equivalent. This of course
* assumes they will be resolved against the same base URI. If this is not
* the case, determination of equivalence or difference of relative
* references does not mean anything.
*
* @param UriInterface $uri1 An URI to compare
* @param UriInterface $uri2 An URI to compare
* @param int $normalizations A bitmask of normalizations to apply, see constants
*
* @link https://tools.ietf.org/html/rfc3986#section-6.1
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-6.1
*/
public static function isEquivalent(UriInterface $uri1, UriInterface $uri2, int $normalizations = self::PRESERVING_NORMALIZATIONS): bool
{
@@ -183,34 +270,183 @@ final class UriNormalizer
private static function capitalizePercentEncoding(UriInterface $uri): UriInterface
{
$regex = '/(?:%[A-Fa-f0-9]{2})++/';
$regex = '/(?:%'.Rfc3986::HEX_OCTET.')++/';
$callback = function (array $match) {
return strtoupper($match[0]);
$callback = function (array $match): string {
return Utils::asciiToUpper($match[0]);
};
return
$uri->withPath(
preg_replace_callback($regex, $callback, $uri->getPath())
)->withQuery(
preg_replace_callback($regex, $callback, $uri->getQuery())
);
$uri = self::withNormalizedUserInfo($uri, $regex, $callback);
$uri = self::withNormalizedHost($uri, $regex, $callback);
return self::withGuardedPath($uri, self::normalizePercentEncodingInComponent(Uri::rawPath($uri), $regex, $callback))
->withQuery(self::normalizePercentEncodingInComponent($uri->getQuery(), $regex, $callback))
->withFragment(self::normalizePercentEncodingInComponent($uri->getFragment(), $regex, $callback));
}
private static function decodeUnreservedCharacters(UriInterface $uri): UriInterface
{
$regex = '/%(?:2D|2E|5F|7E|3[0-9]|[46][1-9A-F]|[57][0-9A])/i';
$callback = function (array $match) {
$callback = function (array $match): string {
return rawurldecode($match[0]);
};
return
$uri->withPath(
preg_replace_callback($regex, $callback, $uri->getPath())
)->withQuery(
preg_replace_callback($regex, $callback, $uri->getQuery())
);
// The host is case-insensitive and PSR-7 requires it to be lowercase,
// so decoded ALPHA octets (e.g. "%41") must land lowercase even for
// implementations whose withHost() does not normalize the case.
$hostCallback = function (array $match): string {
return Utils::asciiToLower(rawurldecode($match[0]));
};
$uri = self::withNormalizedUserInfo($uri, $regex, $callback);
$uri = self::withNormalizedHost($uri, $regex, $hostCallback);
return self::withGuardedPath($uri, self::normalizePercentEncodingInComponent(Uri::rawPath($uri), $regex, $callback))
->withQuery(self::normalizePercentEncodingInComponent($uri->getQuery(), $regex, $callback))
->withFragment(self::normalizePercentEncodingInComponent($uri->getFragment(), $regex, $callback));
}
/**
* Writes the given path only when it differs from the current one, guarded
* so the write cannot throw.
*/
private static function withGuardedPath(UriInterface $uri, string $path): UriInterface
{
if ($path === Uri::rawPath($uri)) {
return $uri;
}
return $uri->withPath(UriResolver::guardedPath($uri, $path));
}
/**
* @param callable(array): string $callback
*/
private static function withNormalizedUserInfo(UriInterface $uri, string $regex, callable $callback): UriInterface
{
$userInfo = $uri->getUserInfo();
if (!str_contains($userInfo, '%')) {
return $uri;
}
$normalized = self::normalizePercentEncodingInComponent($userInfo, $regex, $callback);
if ($normalized === $userInfo) {
return $uri;
}
// Normalization cannot create a colon: decoding is confined to
// unreserved characters and capitalization keeps octets encoded. So
// splitting on the first colon preserves the user/password boundary.
$parts = explode(':', $normalized, 2);
// PSR-7 defines withUserInfo('') as removing the userinfo, so a
// userinfo with an empty user segment (e.g. ":pass") cannot be
// expressed through the setter and is preserved as-is instead.
if ($parts[0] === '') {
return $uri;
}
$candidate = $uri->withUserInfo($parts[0], $parts[1] ?? null);
// Normalization must never lose or corrupt information, so verify the
// representation the setter returned and leave the component untouched
// when the implementation cannot represent the normalized form.
if ($candidate->getUserInfo() !== $normalized) {
return $uri;
}
return $candidate;
}
/**
* @param callable(array): string $callback
*/
private static function withNormalizedHost(UriInterface $uri, string $regex, callable $callback): UriInterface
{
$host = $uri->getHost();
// Bracketed IP-literal hosts are skipped as a legacy tolerance for
// nonstandard values other implementations may carry, such as a zone
// identifier in "[fe80::1%25eth0]"; that text was briefly valid URI
// syntax under RFC 6874, which RFC 9844 obsoleted and reverted.
if (str_starts_with($host, '[') || !str_contains($host, '%')) {
return $uri;
}
$normalized = self::normalizePercentEncodingInComponent($host, $regex, $callback);
if ($normalized === $host) {
return $uri;
}
$candidate = $uri->withHost($normalized);
// Normalization must never lose or corrupt information, so verify the
// representation the setter returned and leave the component untouched
// when the implementation cannot represent the normalized form.
if ($candidate->getHost() !== $normalized) {
return $uri;
}
return $candidate;
}
/**
* @param callable(array): string $callback
*/
private static function normalizePercentEncodingInComponent(string $component, string $regex, callable $callback): string
{
// Decoding a valid triplet that follows a dangling "%" would complete
// the malformed sequence into a new valid triplet ("example%6%31com"
// becomes "example%61com"), turning malformed text valid and breaking
// idempotence, so a component containing malformed percent syntax is
// returned unchanged.
$malformed = preg_match('/%(?!'.Rfc3986::HEX_OCTET.')/', $component);
if ($malformed === false) {
throw new \RuntimeException('Unable to scan URI component percent-encoding: '.preg_last_error_msg());
}
if ($malformed === 1) {
return $component;
}
$normalized = preg_replace_callback($regex, $callback, $component);
if ($normalized === null) {
throw new \RuntimeException('Unable to normalize URI component percent-encoding: '.preg_last_error_msg());
}
return $normalized;
}
private static function canonicalizeIpv6Host(UriInterface $uri): UriInterface
{
$host = $uri->getHost();
if (!str_starts_with($host, '[') || !str_ends_with($host, ']')) {
return $uri;
}
// Foreign UriInterface implementations may carry IPvFuture literals,
// IPv6 zone identifiers, uppercase text, or invalid spellings;
// tryCanonicalizeIpv6() canonicalizes only what is unambiguously an
// IPv6 address and leaves everything else untouched.
$canonical = Rfc3986::tryCanonicalizeIpv6(substr($host, 1, -1));
if ($canonical === null || '['.$canonical.']' === $host) {
return $uri;
}
$candidate = $uri->withHost('['.$canonical.']');
// Normalization must never corrupt a component, so keep the original
// host when the implementation does not retain the canonical form.
if ($candidate->getHost() !== '['.$canonical.']') {
return $uri;
}
return $candidate;
}
private function __construct()
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* @internal
*/
final class UriParser
{
private function __construct()
{
}
/**
* UTF-8 aware \parse_url() replacement.
*
* The internal function produces broken output for non ASCII domain names
* (IDN) when used with locales other than "C".
*
* On the other hand, cURL understands IDN correctly only when UTF-8 locale
* is configured ("C.UTF-8", "en_US.UTF-8", etc.).
*
* @see https://bugs.php.net/bug.php?id=52923
* @see https://www.php.net/manual/en/function.parse-url.php#114817
* @see https://curl.se/libcurl/c/CURLOPT_URL.html#ENCODING
*
* @return array|false
*/
public static function parse(string $url)
{
if (self::isPathNoSchemeReference($url)) {
return self::parsePathNoSchemeReference($url);
}
// Preserve bracketed IP-literals (IPv6 or IPvFuture) in scheme, userinfo,
// and network-path authorities before encoding. Userinfo is encoded
// separately so raw bytes cannot reach parse_url(), which mutates
// control characters instead of failing.
$prefix = '';
$ipv6Prefix = preg_match('%\A((?:[0-9A-Za-z+.-]+:)?//)(?:([^/?#@]*)(@))?(\[[^\]\x00-\x20\x7F/?#@]+\])(.*)\z%s', $url, $matches);
if ($ipv6Prefix === false) {
return false;
}
if ($ipv6Prefix === 1) {
/** @var array{0:string, 1:string, 2:string, 3:string, 4:string, 5:string} $matches */
$suffix = $matches[5];
// After the bracketed host only an optional numeric port and/or a
// path, query, or fragment may follow. Anything else (for example
// `:80@evil` or `:80x`) would let parse_url() reinterpret a
// different host.
if (preg_match('%\A(?::[0-9]*)?(?:[/?#].*)?\z%s', $suffix) !== 1) {
return false;
}
// RFC 3986 IP-literals contain no percent-encoding, so reject any
// "%" in the bracketed host rather than letting the urldecode()
// below turn an encoded octet into a different literal. This keeps
// parsing aligned with withHost()/Rfc3986::isValidHost().
if (str_contains($matches[4], '%')) {
return false;
}
$prefix = $matches[1];
if ($matches[3] === '@') {
/** @var string|null */
$encodedUserInfo = preg_replace_callback(
'%[^:/@?&=#]+%usD',
static function (array $matches): string {
return urlencode($matches[0]);
},
$matches[2]
);
if ($encodedUserInfo === null) {
return false;
}
$prefix .= $encodedUserInfo.'@';
}
$prefix .= $matches[4];
$url = $suffix;
}
/** @var string|null */
$encodedUrl = preg_replace_callback(
'%[^:/@?&=#]+%usD',
static function (array $matches): string {
return urlencode($matches[0]);
},
$url
);
if ($encodedUrl === null) {
return false;
}
$result = parse_url($prefix.$encodedUrl);
if ($result === false) {
return false;
}
return array_map('urldecode', $result);
}
private static function isPathNoSchemeReference(string $url): bool
{
if ($url === '' || str_starts_with($url, '/') || str_starts_with($url, '?') || str_starts_with($url, '#')) {
return false;
}
$firstSegment = substr($url, 0, strcspn($url, '/?#'));
return !str_contains($firstSegment, ':');
}
/**
* @return array{path: string, query?: string, fragment?: string}
*/
private static function parsePathNoSchemeReference(string $url): array
{
$parts = [];
if (false !== ($fragmentPosition = strpos($url, '#'))) {
$parts['fragment'] = substr($url, $fragmentPosition + 1);
$url = substr($url, 0, $fragmentPosition);
}
if (false !== ($queryPosition = strpos($url, '?'))) {
$parts['query'] = substr($url, $queryPosition + 1);
$url = substr($url, 0, $queryPosition);
}
$parts['path'] = $url;
return $parts;
}
}
+159 -61
View File
@@ -11,14 +11,22 @@ use Psr\Http\Message\UriInterface;
*
* @author Tobias Schultze
*
* @link https://tools.ietf.org/html/rfc3986#section-5
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-5
*/
final class UriResolver
{
/**
* Removes dot segments from a path and returns the new path.
* Removes dot segments from a path and returns the new path according to
* RFC 3986 Section 5.2.4.
*
* @link http://tools.ietf.org/html/rfc3986#section-5.2.4
* Excess `..` segments above the root of an absolute path are dropped
* without consuming the root, so the result can begin with `//` (e.g.
* `/..//a` becomes `//a`). Such a path is not valid for a URI without an
* authority (RFC 3986 Section 3.3); `resolve()` and
* `UriNormalizer::normalize()` serialize it with a `/.` prefix in that
* case, like the WHATWG URL Standard.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4
*/
public static function removeDotSegments(string $path): string
{
@@ -28,9 +36,15 @@ final class UriResolver
$results = [];
$segments = explode('/', $path);
// The first segment of an absolute path is the empty root marker producing the
// leading slash. RFC 3986 Section 5.2.4 (2C) drops ".." segments in excess of
// the path hierarchy without consuming the root, so it must never be popped.
$floor = $segments[0] === '' ? 1 : 0;
foreach ($segments as $segment) {
if ($segment === '..') {
array_pop($results);
if (count($results) > $floor) {
array_pop($results);
}
} elseif ($segment !== '.') {
$results[] = $segment;
}
@@ -38,9 +52,9 @@ final class UriResolver
$newPath = implode('/', $results);
if ($path[0] === '/' && (!isset($newPath[0]) || $newPath[0] !== '/')) {
if (str_starts_with($path, '/') && !str_starts_with($newPath, '/')) {
// Re-add the leading slash if necessary for cases like "/.."
$newPath = '/' . $newPath;
$newPath = '/'.$newPath;
} elseif ($newPath !== '' && ($segment === '.' || $segment === '..')) {
// Add the trailing slash if necessary
// If newPath is not empty, then $segment must be set and is the last segment from the foreach
@@ -51,9 +65,58 @@ final class UriResolver
}
/**
* Converts the relative URI into a new URI that is resolved against the base URI.
* Returns the path, prefixed with "/." or "./" when the URI could not
* otherwise hold it.
*
* @link http://tools.ietf.org/html/rfc3986#section-5.2
* A URI without an authority cannot hold a path beginning with "//" (RFC
* 3986 Section 3.3), but removeDotSegments() can produce one. The "/."
* prefix serializes such a path unambiguously, the same way the WHATWG URL
* Standard does, and resolves back to the same path. Hostless http and
* https Uri instances gain the default localhost host when the path is
* written, so the path cannot be mistaken for an authority and the prefix
* is not added.
*
* A relative-path reference cannot begin with a segment containing a colon
* (RFC 3986 Section 4.2), as it would be mistaken for a scheme name, but
* reference resolution and percent-encoding normalization can produce one.
* The "./" prefix the RFC prescribes resolves back to the same path.
*
* @see https://url.spec.whatwg.org/#url-serializing
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
*
* @internal
*/
public static function guardedPath(UriInterface $uri, string $path): string
{
if ($uri->getAuthority() !== '') {
return $path;
}
if (str_starts_with($path, '//')) {
if ($uri instanceof Uri && ($uri->getScheme() === 'http' || $uri->getScheme() === 'https')) {
return $path;
}
return '/.'.$path;
}
if ($uri->getScheme() === '' && str_contains(explode('/', $path, 2)[0], ':')) {
return './'.$path;
}
return $path;
}
/**
* Converts the relative URI into a new URI that is resolved against the
* base URI.
*
* When the resolved path is a relative-path reference whose first segment
* contains a colon, which would be mistaken for a scheme name (RFC 3986
* Section 4.2), it is prefixed with `./`, e.g. `./a:b`.
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.2
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
*/
public static function resolve(UriInterface $base, UriInterface $rel): UriInterface
{
@@ -63,72 +126,76 @@ final class UriResolver
}
if ($rel->getScheme() != '') {
return $rel->withPath(self::removeDotSegments($rel->getPath()));
return $rel->withPath(self::guardedPath($rel, self::removeDotSegments(Uri::rawPath($rel))));
}
if ($rel->getAuthority() != '') {
$targetAuthority = $rel->getAuthority();
$targetPath = self::removeDotSegments($rel->getPath());
$targetQuery = $rel->getQuery();
} else {
$targetAuthority = $base->getAuthority();
if ($rel->getPath() === '') {
$targetPath = $base->getPath();
$targetQuery = $rel->getQuery() != '' ? $rel->getQuery() : $base->getQuery();
} else {
if ($rel->getPath()[0] === '/') {
$targetPath = $rel->getPath();
} else {
if ($targetAuthority != '' && $base->getPath() === '') {
$targetPath = '/' . $rel->getPath();
} else {
$lastSlashPos = strrpos($base->getPath(), '/');
if ($lastSlashPos === false) {
$targetPath = $rel->getPath();
} else {
$targetPath = substr($base->getPath(), 0, $lastSlashPos + 1) . $rel->getPath();
}
}
}
$targetPath = self::removeDotSegments($targetPath);
$targetQuery = $rel->getQuery();
}
return $rel
->withScheme($base->getScheme())
->withPath(self::removeDotSegments(Uri::rawPath($rel)));
}
return new Uri(Uri::composeComponents(
$base->getScheme(),
$targetAuthority,
$targetPath,
$targetQuery,
$rel->getFragment()
));
$relPath = Uri::rawPath($rel);
if ($relPath === '') {
// the base path is used as-is per RFC 3986 Section 5.2.2, so it must not be
// rewritten through a getPath()/withPath() round-trip
return $base
->withQuery($rel->getQuery() != '' ? $rel->getQuery() : $base->getQuery())
->withFragment($rel->getFragment());
}
if (str_starts_with($relPath, '/')) {
$targetPath = $relPath;
} else {
$basePath = Uri::rawPath($base);
if ($base->getAuthority() != '' && $basePath === '') {
$targetPath = '/'.$relPath;
} else {
$lastSlashPos = strrpos($basePath, '/');
if ($lastSlashPos === false) {
$targetPath = $relPath;
} else {
$targetPath = substr($basePath, 0, $lastSlashPos + 1).$relPath;
}
}
}
$targetPath = self::removeDotSegments($targetPath);
return $base
->withPath(self::guardedPath($base, $targetPath))
->withQuery($rel->getQuery())
->withFragment($rel->getFragment());
}
/**
* Returns the target URI as a relative reference from the base URI.
*
* This method is the counterpart to resolve():
* This method is the counterpart to `resolve()`:
*
* (string) $target === (string) UriResolver::resolve($base, UriResolver::relativize($base, $target))
*
* One use-case is to use the current request URI as base URI and then generate relative links in your documents
* to reduce the document size or offer self-contained downloadable document archives.
* One use case is to use the current request URI as the base URI and then
* generate relative links in your documents to reduce the document size or
* offer self-contained downloadable document archives.
*
* $base = new Uri('http://example.com/a/b/');
* echo UriResolver::relativize($base, new Uri('http://example.com/a/b/c')); // prints 'c'.
* echo UriResolver::relativize($base, new Uri('http://example.com/a/x/y')); // prints '../x/y'.
* echo UriResolver::relativize($base, new Uri('http://example.com/a/b/?q')); // prints '?q'.
* echo UriResolver::relativize($base, new Uri('http://example.org/a/b/')); // prints '//example.org/a/b/'.
* echo UriResolver::relativize($base, new Uri('http://example.com')); // prints '//example.com'.
*
* This method also accepts a target that is already relative and will try to relativize it further. Only a
* relative-path reference will be returned as-is.
* This method also accepts a target that is already relative and will try
* to relativize it further. Only a relative-path reference will be returned
* as-is.
*
* echo UriResolver::relativize($base, new Uri('/a/b/c')); // prints 'c' as well
*/
public static function relativize(UriInterface $base, UriInterface $target): UriInterface
{
if ($target->getScheme() !== '' &&
($base->getScheme() !== $target->getScheme() || $target->getAuthority() === '' && $base->getAuthority() !== '')
if ($target->getScheme() !== ''
&& ($base->getScheme() !== $target->getScheme() || $target->getAuthority() === '' && $base->getAuthority() !== '')
) {
return $target;
}
@@ -144,37 +211,68 @@ final class UriResolver
return $target->withScheme('');
}
// A same-authority target with an empty path can only be expressed by a
// network-path reference (RFC 3986 Section 5.2.2).
if (self::needsNetworkPathReference($base, $target)) {
return $target->withScheme('');
}
// We must remove the path before removing the authority because if the path starts with two slashes, the URI
// would turn invalid. And we also cannot set a relative path before removing the authority, as that is also
// invalid.
$emptyPathUri = $target->withScheme('')->withPath('')->withUserInfo('')->withPort(null)->withHost('');
if ($base->getPath() !== $target->getPath()) {
if (Uri::rawPath($base) !== Uri::rawPath($target)) {
return $emptyPathUri->withPath(self::getRelativePath($base, $target));
}
if ($base->getQuery() === $target->getQuery()) {
if ($base->getQuery() === $target->getQuery() && ($target->getFragment() !== '' || $base->getFragment() === '')) {
// Only the target fragment is left. And it must be returned even if base and target fragment are the same.
return $emptyPathUri->withQuery('');
}
// If the base URI has a query but the target has none, we cannot return an empty path reference as it would
// inherit the base query component when resolving.
// If the base URI has a query or fragment that the target lacks, we cannot return an empty path
// reference as it would inherit that base component when resolving.
if ($target->getQuery() === '') {
$segments = explode('/', $target->getPath());
$segments = explode('/', Uri::rawPath($target));
/** @var string $lastSegment */
$lastSegment = end($segments);
return $emptyPathUri->withPath($lastSegment === '' ? './' : $lastSegment);
// A reference to an empty last segment must be prefixed with "./". The same applies
// to a segment with a colon character, which would be mistaken for a scheme name.
if ($lastSegment === '' || str_contains($lastSegment, ':')) {
$lastSegment = "./$lastSegment";
}
return $emptyPathUri->withPath($lastSegment);
}
return $emptyPathUri;
}
/**
* Whether relativizing to $target requires a network-path reference.
*
* A same-authority target with an empty path is expressible by a shorter
* relative reference unless resolving one would inherit a base component
* the target lacks: the base path (kept by any empty-path reference), or
* the base query or fragment (inherited by the empty reference).
*/
private static function needsNetworkPathReference(UriInterface $base, UriInterface $target): bool
{
if ($target->getAuthority() === '' || Uri::rawPath($target) !== '') {
return false;
}
return Uri::rawPath($base) !== ''
|| ($base->getQuery() !== '' && $target->getQuery() === '')
|| ($base->getFragment() !== '' && $target->getFragment() === '' && $base->getQuery() === $target->getQuery());
}
private static function getRelativePath(UriInterface $base, UriInterface $target): string
{
$sourceSegments = explode('/', $base->getPath());
$targetSegments = explode('/', $target->getPath());
$sourceSegments = explode('/', Uri::rawPath($base));
$targetSegments = explode('/', Uri::rawPath($target));
array_pop($sourceSegments);
$targetLastSegment = array_pop($targetSegments);
foreach ($sourceSegments as $i => $segment) {
@@ -185,15 +283,15 @@ final class UriResolver
}
}
$targetSegments[] = $targetLastSegment;
$relativePath = str_repeat('../', count($sourceSegments)) . implode('/', $targetSegments);
$relativePath = str_repeat('../', count($sourceSegments)).implode('/', $targetSegments);
// A reference to am empty last segment or an empty first sub-segment must be prefixed with "./".
// This also applies to a segment with a colon character (e.g., "file:colon") that cannot be used
// as the first segment of a relative-path reference, as it would be mistaken for a scheme name.
if ('' === $relativePath || false !== strpos(explode('/', $relativePath, 2)[0], ':')) {
if ($relativePath === '' || str_contains(explode('/', $relativePath, 2)[0], ':')) {
$relativePath = "./$relativePath";
} elseif ('/' === $relativePath[0]) {
if ($base->getAuthority() != '' && $base->getPath() === '') {
} elseif (str_starts_with($relativePath, '/')) {
if ($base->getAuthority() != '' && Uri::rawPath($base) === '') {
// In this case an extra slash is added by resolve() automatically. So we must not add one here.
$relativePath = ".$relativePath";
} else {
+538 -112
View File
@@ -4,28 +4,91 @@ declare(strict_types=1);
namespace GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
final class Utils
{
private function __construct()
{
}
/**
* Remove the items given by the keys, case insensitively from the data.
* Converts ASCII uppercase letters in a string to lowercase.
*
* @param string[] $keys
* Unlike strtolower(), which honors LC_CTYPE before PHP 8.2, the
* conversion is locale-independent and leaves every non-ASCII byte
* unchanged, as HTTP protocol elements require.
*/
public static function asciiToLower(string $string): string
{
return strtr($string, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
}
/**
* Converts ASCII lowercase letters in a string to uppercase.
*
* Unlike strtoupper(), which honors LC_CTYPE before PHP 8.2, the
* conversion is locale-independent and leaves every non-ASCII byte
* unchanged, as HTTP protocol elements require.
*/
public static function asciiToUpper(string $string): string
{
return strtr($string, 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ');
}
/**
* Converts the first character of a string to uppercase when it is an
* ASCII lowercase letter.
*
* Unlike ucfirst(), which honors LC_CTYPE before PHP 8.2, the conversion
* is locale-independent and leaves every non-ASCII byte unchanged, as
* HTTP protocol elements require.
*/
public static function asciiUcFirst(string $string): string
{
if ($string === '') {
return '';
}
return self::asciiToUpper($string[0]).substr($string, 1);
}
/**
* Checks whether the haystack contains the needle, comparing ASCII
* letters case-insensitively and without locale sensitivity.
*/
public static function caselessContains(string $haystack, string $needle): bool
{
return str_contains(self::asciiToLower($haystack), self::asciiToLower($needle));
}
/**
* Checks whether two strings are equal, comparing ASCII letters
* case-insensitively and without locale sensitivity.
*/
public static function caselessEquals(string $left, string $right): bool
{
return self::asciiToLower($left) === self::asciiToLower($right);
}
/**
* Remove the items given by the keys from the data, case-insensitively.
*
* @param array<array-key, string|int> $keys
*/
public static function caselessRemove(array $keys, array $data): array
{
$result = [];
foreach ($keys as &$key) {
$key = strtolower($key);
$key = self::asciiToLower((string) $key);
}
foreach ($data as $k => $v) {
if (!is_string($k) || !in_array(strtolower($k), $keys)) {
if (!in_array(self::asciiToLower((string) $k), $keys)) {
$result[$k] = $v;
}
}
@@ -35,7 +98,21 @@ final class Utils
/**
* Copy the contents of a stream into another stream until the given number
* of bytes have been read.
* of bytes have been read, returning the number of bytes copied as an
* `int`. On 32-bit PHP, an unbounded copy larger than `PHP_INT_MAX` bytes
* cannot be represented by that return type. 64-bit PHP is not affected.
*
* The destination must accept writes that make positive progress. Streams
* that return 0 as a backpressure or drop signal (a `BufferStream` at its
* high water mark, or a full `DroppingStream`) will cause this method to
* throw. For full copies, use a normal writable stream such as a file or
* `php://temp` stream.
*
* Throws `TimeoutException` when PHP-style timeout metadata can be detected
* after a source read or destination write cannot make progress.
*
* When a source read times out, the bytes copied before it remain in the
* destination.
*
* @param StreamInterface $source Stream to read from
* @param StreamInterface $dest Stream to write to
@@ -44,34 +121,74 @@ final class Utils
*
* @throws \RuntimeException on error.
*/
public static function copyToStream(StreamInterface $source, StreamInterface $dest, int $maxLen = -1): void
public static function copyToStream(StreamInterface $source, StreamInterface $dest, int $maxLen = -1): int
{
$bufferSize = 8192;
$copied = 0;
if ($maxLen === -1) {
while (!$source->eof()) {
if (!$dest->write($source->read($bufferSize))) {
$buf = StreamTimeout::read($source, $bufferSize, 'Unable to read from stream: timed out');
if ($buf === '') {
break;
}
self::writeAll($dest, $buf);
$copied = Integers::add($copied, strlen($buf));
}
} else {
$remaining = $maxLen;
while ($remaining > 0 && !$source->eof()) {
$buf = $source->read(min($bufferSize, $remaining));
$buf = StreamTimeout::read($source, min($bufferSize, $remaining), 'Unable to read from stream: timed out');
$len = strlen($buf);
if (!$len) {
break;
}
$remaining -= $len;
$dest->write($buf);
self::writeAll($dest, $buf);
$copied = Integers::add($copied, $len);
}
}
return $copied;
}
private static function writeAll(StreamInterface $dest, string $buf): void
{
$written = 0;
$len = strlen($buf);
while ($written < $len) {
try {
$result = $dest->write(substr($buf, $written));
} catch (TimeoutException $e) {
throw $e;
} catch (\RuntimeException $e) {
StreamTimeout::throwIfWriteTimedOut($dest, $e);
throw $e;
}
if ($result <= 0) {
StreamTimeout::throwIfWriteTimedOut($dest);
throw new \RuntimeException('Unable to write to stream');
}
$written += $result;
}
}
/**
* Copy the contents of a stream into a string until the given number of
* bytes have been read.
*
* Throws `TimeoutException` when PHP-style timeout metadata can be detected
* after a stream read cannot make progress.
*
* The bytes read before a timeout are discarded, so calling this method
* again does not return them.
*
* @param StreamInterface $stream Stream to read
* @param int $maxLen Maximum number of bytes to read. Pass -1
* to read the entire stream.
@@ -84,18 +201,19 @@ final class Utils
if ($maxLen === -1) {
while (!$stream->eof()) {
$buf = $stream->read(1048576);
$buf = StreamTimeout::read($stream, 1048576, 'Unable to read from stream: timed out');
if ($buf === '') {
break;
}
$buffer .= $buf;
}
return $buffer;
}
$len = 0;
while (!$stream->eof() && $len < $maxLen) {
$buf = $stream->read($maxLen - $len);
$buf = StreamTimeout::read($stream, $maxLen - $len, 'Unable to read from stream: timed out');
if ($buf === '') {
break;
}
@@ -109,8 +227,11 @@ final class Utils
/**
* Calculate a hash of a stream.
*
* This method reads the entire stream to calculate a rolling hash, based
* on PHP's `hash_init` functions.
* This method reads the entire stream to calculate a rolling hash, based on
* PHP's `hash_init` functions.
*
* Throws `TimeoutException` when PHP-style timeout metadata can be detected
* after a stream read cannot make progress.
*
* @param StreamInterface $stream Stream to calculate the hash for
* @param string $algo Hash algorithm (e.g. md5, crc32, etc)
@@ -128,7 +249,12 @@ final class Utils
$ctx = hash_init($algo);
while (!$stream->eof()) {
hash_update($ctx, $stream->read(1048576));
$buf = StreamTimeout::read($stream, 1048576, 'Unable to calculate stream hash: timed out');
if ($buf === '') {
break;
}
hash_update($ctx, $buf);
}
$out = hash_final($ctx, $rawOutput);
@@ -145,15 +271,31 @@ final class Utils
*
* The changes can be one of:
* - method: (string) Changes the HTTP method.
* - set_headers: (array) Sets the given headers.
* - remove_headers: (array) Remove the given headers.
* - body: (mixed) Sets the given body.
* - uri: (UriInterface) Set the URI.
* - set_headers: (array) Sets the given headers. Values must be strings
* or non-empty arrays of strings.
* - remove_headers: (array) Remove the given headers. Values may be
* strings or integers.
* - body: (mixed) Sets the given body. Present non-null values are
* converted with self::streamFor(), including resources, streams,
* iterators, callable arrays, closures, invokable objects, and stringable
* objects. String inputs remain literal bodies.
* - uri: (UriInterface) Set the URI. When the URI contains a host, the
* Host header is updated from it, and combining this with an explicit
* Host entry in set_headers throws an InvalidArgumentException. Apply
* an intentional Host override separately with withHeader() afterwards.
* - query: (string) Set the query string value of the URI.
* - version: (string) Set the protocol version.
*
* @param RequestInterface $request Request to clone and modify.
* @param array $changes Changes to apply.
* @param array{
* method?: string,
* set_headers?: array<array-key, string|non-empty-array<array-key, string>>,
* remove_headers?: array<array-key, string|int>,
* body?: resource|string|StreamInterface|callable|\Iterator|\Stringable,
* uri?: UriInterface,
* query?: string,
* version?: string
* } $changes Changes to apply.
*/
public static function modifyRequest(RequestInterface $request, array $changes): RequestInterface
{
@@ -161,24 +303,41 @@ final class Utils
return $request;
}
self::assertValidModifyRequestChanges($changes);
$headers = $request->getHeaders();
if (!isset($changes['uri'])) {
$uri = $request->getUri();
} else {
// Remove the host header if one is on the URI
if ($host = $changes['uri']->getHost()) {
/** @var UriInterface */
$uri = $changes['uri'];
$host = $uri->getHost();
if ($host !== '') {
Uri::assertValidHost($host);
if (isset($changes['set_headers']) && is_array($changes['set_headers'])) {
foreach (array_keys($changes['set_headers']) as $header) {
if (self::asciiToLower((string) $header) === 'host') {
throw new \InvalidArgumentException(
'Cannot modify request with both a URI containing a host and an explicit Host header.'
);
}
}
}
$changes['set_headers']['Host'] = $host;
if ($port = $changes['uri']->getPort()) {
$port = $uri->getPort();
if ($port !== null) {
$standardPorts = ['http' => 80, 'https' => 443];
$scheme = $changes['uri']->getScheme();
if (isset($standardPorts[$scheme]) && $port != $standardPorts[$scheme]) {
$changes['set_headers']['Host'] .= ':' . $port;
$scheme = $uri->getScheme();
if (!isset($standardPorts[$scheme]) || $port != $standardPorts[$scheme]) {
$changes['set_headers']['Host'] .= ':'.$port;
}
}
}
$uri = $changes['uri'];
}
if (!empty($changes['remove_headers'])) {
@@ -194,39 +353,156 @@ final class Utils
$uri = $uri->withQuery($changes['query']);
}
if ($request instanceof ServerRequestInterface) {
$new = (new ServerRequest(
$changes['method'] ?? $request->getMethod(),
$uri,
$headers,
$changes['body'] ?? $request->getBody(),
$changes['version'] ?? $request->getProtocolVersion(),
$request->getServerParams()
))
->withParsedBody($request->getParsedBody())
->withQueryParams($request->getQueryParams())
->withCookieParams($request->getCookieParams())
->withUploadedFiles($request->getUploadedFiles());
foreach ($request->getAttributes() as $key => $value) {
$new = $new->withAttribute($key, $value);
$hasHost = false;
foreach (array_keys($headers) as $header) {
if (self::asciiToLower((string) $header) === 'host') {
$hasHost = true;
break;
}
return $new;
}
return new Request(
$changes['method'] ?? $request->getMethod(),
$uri,
$headers,
$changes['body'] ?? $request->getBody(),
$changes['version'] ?? $request->getProtocolVersion()
);
// Match Request::__construct() by adding a Host header when one is not provided.
if (!$hasHost && $uri->getHost() !== '') {
$host = $uri->getHost();
Uri::assertValidHost($host);
if (($port = $uri->getPort()) !== null) {
$host .= ':'.$port;
}
$headers = ['Host' => [$host]] + $headers;
}
$new = $request;
if (isset($changes['method'])) {
$new = $new->withMethod($changes['method']);
}
if (isset($changes['uri']) || isset($changes['query'])) {
$new = $new->withUri($uri, true);
}
if ($headers !== $new->getHeaders()) {
foreach (array_keys($new->getHeaders()) as $header) {
/** @var RequestInterface */
$new = $new->withoutHeader((string) $header);
}
$addedHeaders = [];
foreach ($headers as $header => $value) {
$header = (string) $header;
$normalized = self::asciiToLower($header);
if (isset($addedHeaders[$normalized])) {
/** @var RequestInterface */
$new = $new->withAddedHeader($addedHeaders[$normalized], $value);
} else {
/** @var RequestInterface */
$new = $new->withHeader($header, $value);
$addedHeaders[$normalized] = $header;
}
}
}
if (isset($changes['body'])) {
/** @var RequestInterface */
$new = $new->withBody(self::streamFor($changes['body']));
}
if (isset($changes['version'])) {
/** @var RequestInterface */
$new = $new->withProtocolVersion($changes['version']);
}
return $new;
}
/**
* @param array<array-key, mixed> $changes
*/
private static function assertValidModifyRequestChanges(array $changes): void
{
foreach (['method', 'query', 'version'] as $key) {
if (\array_key_exists($key, $changes) && !\is_string($changes[$key])) {
self::assertValidModifyRequestChange($key, 'string', $changes[$key]);
}
}
if (\array_key_exists('uri', $changes) && !$changes['uri'] instanceof UriInterface) {
self::assertValidModifyRequestChange('uri', 'UriInterface', $changes['uri']);
}
if (\array_key_exists('body', $changes) && $changes['body'] === null) {
self::assertValidModifyRequestChange('body', 'resource|string|StreamInterface|callable|\Iterator|\Stringable', $changes['body']);
}
if (\array_key_exists('set_headers', $changes)) {
if (!\is_array($changes['set_headers'])) {
self::assertValidModifyRequestChange('set_headers', 'array<array-key, string|non-empty-array<array-key, string>>', $changes['set_headers']);
} else {
foreach ($changes['set_headers'] as $header => $value) {
$headerPath = \sprintf('set_headers.%s', (string) $header);
if (\is_array($value)) {
if ($value === []) {
self::assertValidModifyRequestChange($headerPath, 'string|non-empty-array<array-key, string>', $value);
break;
}
foreach ($value as $index => $item) {
if (!\is_string($item)) {
self::assertValidModifyRequestChange(\sprintf('%s.%s', $headerPath, (string) $index), 'string', $item);
break 2;
}
}
} elseif (!\is_string($value)) {
self::assertValidModifyRequestChange($headerPath, 'string|non-empty-array<array-key, string>', $value);
break;
}
}
}
}
if (!\array_key_exists('remove_headers', $changes)) {
return;
}
if (!\is_array($changes['remove_headers'])) {
self::assertValidModifyRequestChange('remove_headers', 'array<array-key, string|int>', $changes['remove_headers']);
return;
}
foreach ($changes['remove_headers'] as $index => $header) {
if (!\is_string($header) && !\is_int($header)) {
self::assertValidModifyRequestChange(\sprintf('remove_headers.%s', (string) $index), 'string|int', $header);
return;
}
}
}
/**
* @param mixed $value
*/
private static function assertValidModifyRequestChange(string $key, string $expected, $value): void
{
throw new \InvalidArgumentException(\sprintf('Utils::modifyRequest() change "%s" must be %s; %s provided.', DiagnosticValue::escape($key), $expected, \get_debug_type($value)));
}
/**
* Read a line from the stream up to the maximum allowed buffer length.
*
* Throws `TimeoutException` when PHP-style timeout metadata can be detected
* after a stream read cannot make progress.
*
* The bytes read before a timeout are discarded, so calling this method
* again does not return them.
*
* @param StreamInterface $stream Stream to read from
* @param int|null $maxLength Maximum buffer length
*/
@@ -236,7 +512,7 @@ final class Utils
$size = 0;
while (!$stream->eof()) {
if ('' === ($byte = $stream->read(1))) {
if ('' === ($byte = StreamTimeout::read($stream, 1, 'Unable to read line from stream: timed out'))) {
return $buffer;
}
$buffer .= $byte;
@@ -249,48 +525,183 @@ final class Utils
return $buffer;
}
/**
* Redact the user info part of a URI.
*
* Returns the URI with the whole userinfo component replaced by "***"
* when one is present, so neither the username nor the password survives
* into logs and diagnostics. A URI without userinfo is returned
* unchanged.
*/
public static function redactUserInfo(
#[\SensitiveParameter]
UriInterface $uri
): UriInterface {
return $uri->getUserInfo() === '' ? $uri : $uri->withUserInfo('***');
}
/**
* Redacts the userinfo of a raw URI string wherever it appears in a
* subject string.
*
* The needle is taken verbatim from the raw URI rather than from parsed
* components, so credentials that URI normalization would rewrite, such
* as raw control bytes or unencoded reserved characters, are still found
* in text that embeds the URI exactly as given, for example transport
* error messages. A URI without "://" is treated as authority-form: a
* host and port with optional userinfo.
*
* A URI that does not parse has no trustworthy authority boundary, so
* everything between any scheme and its last "@" is redacted as a safe-side
* fallback.
*
* @param string $subject Text that may embed the URI
* @param string $uri Raw URI whose userinfo is redacted in the text
*/
public static function redactUserInfoInString(string $subject, string $uri): string
{
if (\strpos($uri, '@') === false) {
return $subject;
}
$schemePosition = \strpos($uri, '://');
$remainder = $schemePosition === false ? $uri : \substr($uri, $schemePosition + 3);
if (\parse_url($schemePosition === false ? 'http://'.$uri : $uri) === false) {
// Raw '/', '?', or '#' separators may sit inside the credentials
// of a URI that defeats parse_url(), so the redaction cannot stop
// at the apparent authority.
$atPosition = \strrpos($remainder, '@');
if ($atPosition === false || $atPosition === 0) {
return $subject;
}
return \str_replace(\substr($remainder, 0, $atPosition).'@', '***@', $subject);
}
$authority = \substr($remainder, 0, \strcspn($remainder, '/?#'));
$atPosition = \strrpos($authority, '@');
if ($atPosition === false || $atPosition === 0) {
// A parseable URI with '@' only past its authority, or with an
// empty userinfo, carries no credentials to redact.
return $subject;
}
return \str_replace(\substr($authority, 0, $atPosition).'@', '***@', $subject);
}
/**
* Formats a URI for automatic diagnostics.
*
* The whole userinfo component is replaced by "***", and the query and
* fragment are removed. The scheme, host, port, and path are preserved.
* The returned string is diagnostic-escaped and the formatter never
* throws.
*/
public static function redactUriForMessage(
#[\SensitiveParameter]
UriInterface $uri
): string {
try {
$raw = (string) $uri;
} catch (\Throwable $e) {
return '[unavailable URI]';
}
try {
if ($uri->getUserInfo() !== '') {
$uri = $uri->withUserInfo('***');
}
return DiagnosticValue::escape((string) $uri->withQuery('')->withFragment(''));
} catch (\Throwable $e) {
return self::redactUriStringForMessage($raw);
}
}
/**
* Formats a raw URI for automatic diagnostics, including malformed input.
*
* The whole userinfo component is replaced by "***", and the query and
* fragment are removed. The scheme, host, port, and path are preserved
* where their boundaries can be determined safely. The returned string is
* diagnostic-escaped and the formatter never throws.
*/
public static function redactUriStringForMessage(
#[\SensitiveParameter]
string $uri
): string {
try {
$uri = self::redactUserInfoInString($uri, $uri);
return DiagnosticValue::escape(\substr($uri, 0, \strcspn($uri, '?#')));
} catch (\Throwable $e) {
return '[unavailable URI]';
}
}
/**
* Create a new stream based on the input type.
*
* Options is an associative array that can contain the following keys:
* Options are provided as an associative array that can contain the
* following keys:
* - metadata: Array of custom metadata.
* - size: Size of the stream.
*
* This method accepts the following `$resource` types:
* - `Psr\Http\Message\StreamInterface`: Returns the value as-is.
* - `string`: Creates a stream object that uses the given string as the contents.
* - `resource`: Creates a stream object that wraps the given PHP stream resource.
* - `Iterator`: If the provided value implements `Iterator`, then a read-only
* stream object will be created that wraps the given iterable. Each time the
* stream is read from, data from the iterator will fill a buffer and will be
* continuously called until the buffer is equal to the requested read size.
* Subsequent read calls will first read from the buffer and then call `next`
* on the underlying iterator until it is exhausted.
* - `object` with `__toString()`: If the object has the `__toString()` method,
* the object will be cast to a string and then a stream will be returned that
* uses the string value.
* - `string`: Creates a stream object that uses the given string as the
* contents.
* - `resource`: Creates a stream object that wraps the given PHP stream
* resource.
* - `Iterator`: If the provided value implements `Iterator`, then a
* read-only stream object will be created that wraps the given iterable.
* Each time the stream is read from, data from the iterator will fill a
* buffer and will be continuously called until the buffer is equal to the
* requested read size. Yielded strings, integers, finite floats,
* booleans, `null`, and stringable objects are converted to string
* chunks; non-finite floats and other values throw
* `UnexpectedValueException` when the stream is read. Values that
* stringify to an empty string are skipped while the iterator advances.
* Subsequent read calls will first read from the buffer and then call
* `next` on the underlying iterator until it is exhausted.
* - `object` with `__toString()`: If the object has the `__toString()`
* method, the object will be cast to a string and then a stream will be
* returned that uses the string value.
* - `NULL`: When `null` is passed, an empty stream object is returned.
* - `callable` When a callable is passed, a read-only stream object will be
* created that invokes the given callable. The callable is invoked with the
* number of suggested bytes to read. The callable can return any number of
* bytes, but MUST return `false` when there is no more data to return. The
* stream object that wraps the callable will invoke the callable until the
* number of requested bytes are available. Any additional bytes will be
* buffered and used in subsequent reads.
* - `callable`: When a callable array, closure, or invokable object is
* passed and no earlier resource or object rule applies, a read-only
* stream object will be created that invokes the given callable. The
* callable is invoked with the suggested number of bytes to read. The
* callable can return fewer or more bytes than requested, but MUST return
* a non-empty string to provide data and MUST return `false` or `null`
* when there is no more data to return. Any additional bytes will be
* buffered and used in subsequent reads. String inputs are always treated
* as string bodies, even when they name callable functions.
*
* @param resource|string|int|float|bool|StreamInterface|callable|\Iterator|null $resource Entity body data
* @param array{size?: int, metadata?: array} $options Additional options
* @param resource|string|StreamInterface|callable|\Iterator|\Stringable|null $resource Entity body data
* @param array{size?: int, metadata?: array} $options Additional options
*
* @throws \InvalidArgumentException if the $resource arg is not valid.
*/
public static function streamFor($resource = '', array $options = []): StreamInterface
{
if (is_scalar($resource)) {
if (!is_string($resource)) {
throw new \InvalidArgumentException(\sprintf(
'Cannot create a stream from %s; pass a string, resource, StreamInterface, Stringable, Iterator, callable, or null.',
\get_debug_type($resource)
));
}
$stream = self::tryFopen('php://temp', 'r+');
if ($resource !== '') {
fwrite($stream, (string) $resource);
fwrite($stream, $resource);
fseek($stream, 0);
}
return new Stream($stream, $options);
}
@@ -308,19 +719,36 @@ final class Utils
fseek($stream, 0);
$resource = $stream;
}
return new Stream($resource, $options);
case 'object':
/** @var object $resource */
if ($resource instanceof StreamInterface) {
return $resource;
} elseif ($resource instanceof \Iterator) {
return new PumpStream(function () use ($resource) {
if (!$resource->valid()) {
return false;
return new PumpStream(function (int $length) use ($resource) {
while ($resource->valid()) {
$result = $resource->current();
$resource->next();
if (is_float($result) && !is_finite($result)) {
throw new \UnexpectedValueException('Iterator must not yield non-finite float values');
}
if ($result === null || is_scalar($result)) {
$data = (string) $result;
} elseif (is_object($result) && method_exists($result, '__toString')) {
$data = (string) $result;
} else {
throw new \UnexpectedValueException('Iterator must yield scalar, null, or stringable values');
}
if ($data !== '') {
return $data;
}
}
$result = $resource->current();
$resource->next();
return $result;
return false;
}, $options);
} elseif (method_exists($resource, '__toString')) {
return self::streamFor((string) $resource, $options);
@@ -334,14 +762,14 @@ final class Utils
return new PumpStream($resource, $options);
}
throw new \InvalidArgumentException('Invalid resource type: ' . gettype($resource));
throw new \InvalidArgumentException('Invalid resource type: '.\get_debug_type($resource));
}
/**
* Safely opens a PHP stream resource using a filename.
*
* When fopen fails, PHP normally raises a warning. This function adds an
* error handler that checks for errors and throws an exception instead.
* When `fopen()` fails, PHP normally raises a warning. This function adds
* an error handler that checks for errors and throws an exception instead.
*
* @param string $filename File to open
* @param string $mode Mode used to open the file
@@ -354,12 +782,7 @@ final class Utils
{
$ex = null;
set_error_handler(static function (int $errno, string $errstr) use ($filename, $mode, &$ex): bool {
$ex = new \RuntimeException(sprintf(
'Unable to open "%s" using mode "%s": %s',
$filename,
$mode,
$errstr
));
$ex = new \RuntimeException(sprintf('Unable to open %s using mode %s: %s', DiagnosticValue::escape($filename), DiagnosticValue::escape($mode), DiagnosticValue::escape($errstr)));
return true;
});
@@ -368,18 +791,13 @@ final class Utils
/** @var resource $handle */
$handle = fopen($filename, $mode);
} catch (\Throwable $e) {
$ex = new \RuntimeException(sprintf(
'Unable to open "%s" using mode "%s": %s',
$filename,
$mode,
$e->getMessage()
), 0, $e);
$ex = new \RuntimeException(sprintf('Unable to open %s using mode %s: %s', DiagnosticValue::escape($filename), DiagnosticValue::escape($mode), $e->getMessage()), 0, $e);
}
restore_error_handler();
if ($ex) {
/** @var $ex \RuntimeException */
/** @var \RuntimeException $ex */
throw $ex;
}
@@ -389,10 +807,16 @@ final class Utils
/**
* Safely gets the contents of a given stream.
*
* When stream_get_contents fails, PHP normally raises a warning. This
* When `stream_get_contents()` fails, PHP normally raises a warning. This
* function adds an error handler that checks for errors and throws an
* exception instead.
*
* Throws `TimeoutException` when PHP-style timeout metadata can be detected
* after a stream read cannot make progress.
*
* The bytes read before a timeout are discarded, so calling this method
* again does not return them.
*
* @param resource $stream
*
* @throws \RuntimeException if the stream cannot be read
@@ -401,10 +825,7 @@ final class Utils
{
$ex = null;
set_error_handler(static function (int $errno, string $errstr) use (&$ex): bool {
$ex = new \RuntimeException(sprintf(
'Unable to read stream contents: %s',
$errstr
));
$ex = new \RuntimeException(sprintf('Unable to read stream contents: %s', DiagnosticValue::escape($errstr)));
return true;
});
@@ -414,19 +835,24 @@ final class Utils
$contents = stream_get_contents($stream);
if ($contents === false) {
$ex = new \RuntimeException('Unable to read stream contents');
$ex = StreamTimeout::isResourceReadTimedOut($stream)
? new TimeoutException('Unable to read stream contents: timed out')
: new \RuntimeException('Unable to read stream contents');
} elseif (StreamTimeout::isResourceReadTimedOut($stream)) {
$ex = new TimeoutException('Unable to read stream contents: timed out');
}
} catch (TimeoutException $e) {
$ex = $e;
} catch (\Throwable $e) {
$ex = new \RuntimeException(sprintf(
'Unable to read stream contents: %s',
$e->getMessage()
), 0, $e);
$ex = StreamTimeout::isResourceReadTimedOut($stream)
? new TimeoutException('Unable to read stream contents: timed out', 0, $e)
: new \RuntimeException(sprintf('Unable to read stream contents: %s', $e->getMessage()), 0, $e);
}
restore_error_handler();
if ($ex) {
/** @var $ex \RuntimeException */
/** @var \RuntimeException $ex */
throw $ex;
}
@@ -434,11 +860,11 @@ final class Utils
}
/**
* Returns a UriInterface for the given value.
* Returns a `UriInterface` for the given value.
*
* This function accepts a string or UriInterface and returns a
* UriInterface for the given value. If the value is already a
* UriInterface, it is returned as-is.
* This function accepts a string or `UriInterface` and returns a
* `UriInterface` for the given value. If the value is already a
* `UriInterface`, it is returned as-is.
*
* @param string|UriInterface $uri
*