diff --git a/.changelog/consistent-skill-sources.md b/.changelog/consistent-skill-sources.md new file mode 100644 index 0000000..973c40e --- /dev/null +++ b/.changelog/consistent-skill-sources.md @@ -0,0 +1,11 @@ +--- +category: changed +type: patch +--- + +Synchronize the bundled local skill automatically and trust Fast Forward Composer +skill donors through `fast-forward/*`. +Resolve Changelog contributor skills from stable `0.4.x` releases without a +redundant skill allowlist. +Load the project autoloader for PHPDoc checks when DevTools is installed as a +dependency. diff --git a/.gitattributes b/.gitattributes index 84ffdce..4de4194 100755 --- a/.gitattributes +++ b/.gitattributes @@ -17,3 +17,4 @@ /docs/ export-ignore /README*.md export-ignore /skills.json export-ignore +/.php-cs-fixer.dist.php export-ignore diff --git a/.gitignore b/.gitignore index 5411d4f..7265bc3 100755 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ composer.lock *.cache TO-DO.md /.agents/ +/.dev-tools/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..aadca2d --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,38 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/http-message + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +use FastForward\DevTools\Path\WorkingProjectPathResolver; +use PhpCsFixer\Config; +use PhpCsFixer\Finder; + +require __DIR__ . '/vendor/autoload.php'; + +return (new Config()) + ->setRiskyAllowed(false) + ->setFinder(Finder::create()->in(__DIR__)->exclude(WorkingProjectPathResolver::TOOLING_EXCLUDED_DIRECTORIES)) + ->setRules([ + 'phpdoc_indent' => true, + 'phpdoc_order' => ['order' => ['param', 'return', 'throws']], + 'phpdoc_separation' => true, + 'phpdoc_trim' => true, + 'phpdoc_trim_consecutive_blank_line_separation' => true, + 'phpdoc_scalar' => true, + 'phpdoc_types' => true, + 'phpdoc_to_comment' => false, + 'phpdoc_add_missing_param_annotation' => true, + ]); diff --git a/composer.json b/composer.json index a53e535..b966945 100644 --- a/composer.json +++ b/composer.json @@ -41,10 +41,11 @@ "allow-plugins": { "ergebnis/composer-normalize": true, "fast-forward/dev-tools": true, + "llm/skills": true, "phpdocumentor/shim": true, "phpro/grumphp": true, - "pyrech/composer-changelogs": true, - "llm/skills": true + "phpro/grumphp-shim": true, + "pyrech/composer-changelogs": true }, "platform": { "php": "8.3.0" diff --git a/skills.json b/skills.json index 386ea94..47b322e 100644 --- a/skills.json +++ b/skills.json @@ -1,21 +1,27 @@ { - "$schema": "https://raw.githubusercontent.com/roxblnfk/skills/master/resources/skills.schema.json", + "$schema": "https://raw.githubusercontent.com/roxblnfk/skills/1.15.0/resources/skills.schema.json", "target": ".agents/skills", "aliases": [], "discovery": false, "auto-sync": true, "vendor-sources": false, "dependencies": { - "composer": false + "composer": { + "trusted": [ + "fast-forward/*" + ], + "trusted-replace": true + } }, "sources": [ + { + "from": "dir", + "path": "resources/skills" + }, { "from": "github", "package": "php-fast-forward/changelog", - "ref": "^0.3.0", - "skills": [ - "fast-forward-changelog" - ] + "ref": "^0.4.0" } ] } diff --git a/src/Header/Accept.php b/src/Header/Accept.php index 252ba21..303402d 100644 --- a/src/Header/Accept.php +++ b/src/Header/Accept.php @@ -79,6 +79,7 @@ public static function getBestMatch(string $acceptHeader, array $supportedTypes) * Finds the most specific applicable range before considering its quality. * * @param array $preferences + * @param string $supported * * @return array{type: string, q: float, specificity: int}|null */ @@ -99,6 +100,8 @@ private static function findEffectivePreference(array $preferences, string $supp * Parses media ranges in field order. These enum cases represent bare media * types, so ranges requiring media parameters do not match them. * + * @param string $header + * * @return array */ private static function parseHeader(string $header): array @@ -116,17 +119,20 @@ private static function parseHeader(string $header): array [$name, $value] = array_pad(explode('=', $parameter, 2), 2, ''); if ('q' === strtolower(trim($name, " \t"))) { $value = trim($value, " \t"); - if (!preg_match('/^(?:0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?)$/D', $value)) { + if (! preg_match('/^(?:0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?)$/D', $value)) { continue 2; } + $q = (float) $value; break; - } elseif ('' !== trim($name, " \t")) { + } + + if ('' !== trim($name, " \t")) { $hasMediaParameters = true; } } - if (!$hasMediaParameters) { + if (! $hasMediaParameters) { $preferences[] = [ 'type' => $type, 'q' => $q, @@ -141,6 +147,8 @@ private static function parseHeader(string $header): array /** * Splits only list commas outside quoted strings, honoring quoted-pair escapes. * + * @param string $header + * * @return list */ private static function splitRanges(string $header): array @@ -149,7 +157,7 @@ private static function splitRanges(string $header): array $start = 0; $quoted = false; $escaped = false; - $length = strlen($header); + $length = \strlen($header); for ($index = 0; $index < $length; ++$index) { $character = $header[$index]; @@ -157,17 +165,19 @@ private static function splitRanges(string $header): array $escaped = false; continue; } + if ($quoted && '\\' === $character) { $escaped = true; } elseif ('"' === $character) { - $quoted = !$quoted; - } elseif (',' === $character && !$quoted) { + $quoted = ! $quoted; + } elseif (',' === $character && ! $quoted) { $ranges[] = substr($header, $start, $index - $start); $start = $index + 1; } } $ranges[] = substr($header, $start); + return $ranges; } diff --git a/src/Header/Authorization.php b/src/Header/Authorization.php index ff4b4ca..33bf774 100644 --- a/src/Header/Authorization.php +++ b/src/Header/Authorization.php @@ -129,6 +129,7 @@ public static function parse(string $header): ?AuthorizationCredential break; } } + if (null === $authScheme) { return null; } diff --git a/src/Header/TransferEncoding.php b/src/Header/TransferEncoding.php index fad92d7..f0bafb2 100644 --- a/src/Header/TransferEncoding.php +++ b/src/Header/TransferEncoding.php @@ -113,7 +113,7 @@ public static function isChunked(string $transferEncodingHeader): bool } $codings = array_map( - static fn (string $coding): string => trim($coding, " \t"), + static fn(string $coding): string => trim($coding, " \t"), explode(',', strtolower($transferEncodingHeader)), ); diff --git a/src/JsonResponse.php b/src/JsonResponse.php index 19955ea..9c39811 100644 --- a/src/JsonResponse.php +++ b/src/JsonResponse.php @@ -19,7 +19,9 @@ namespace FastForward\Http\Message; use FastForward\Http\Message\Header\ContentType; +use JsonException; use Nyholm\Psr7\Response; +use RuntimeException; /** * Class JsonResponse. @@ -60,10 +62,10 @@ public function __construct(mixed $payload = [], string $charset = 'utf-8', arra * Invalid JSON throws JsonException; unreadable, detached or non-seekable * generic streams throw RuntimeException rather than being consumed. * - * @throws \JsonException - * @throws \RuntimeException - * * @return mixed the decoded JSON payload + * + * @throws JsonException + * @throws RuntimeException */ public function getPayload(): mixed { @@ -72,13 +74,14 @@ public function getPayload(): mixed return $body->getPayload(); } - if (!$body->isReadable() || !$body->isSeekable()) { - throw new \RuntimeException('A generic JSON body must be readable and seekable to retrieve its payload.'); + if (! $body->isReadable() || ! $body->isSeekable()) { + throw new RuntimeException('A generic JSON body must be readable and seekable to retrieve its payload.'); } $position = $body->tell(); try { $body->rewind(); + return json_decode($body->getContents(), true, 512, \JSON_THROW_ON_ERROR); } finally { $body->seek($position); @@ -99,6 +102,7 @@ public function getPayload(): mixed public function withPayload(mixed $payload): self { $body = $this->getBody(); + return $this->withBody( $body instanceof PayloadStreamInterface ? $body->withPayload($payload) : new JsonStream($payload) ); diff --git a/src/RequestMethod.php b/src/RequestMethod.php index 89d2e8f..ee10778 100644 --- a/src/RequestMethod.php +++ b/src/RequestMethod.php @@ -30,34 +30,54 @@ */ enum RequestMethod: string { - /** The HEAD method requests the headers for a given resource without the response body. */ + /** + * The HEAD method requests the headers for a given resource without the response body. + */ case Head = 'HEAD'; - /** The GET method requests a representation of the specified resource. It MUST NOT have side-effects. */ + /** + * The GET method requests a representation of the specified resource. It MUST NOT have side-effects. + */ case Get = 'GET'; - /** The POST method submits data to be processed, often causing a change in state or side-effects. */ + /** + * The POST method submits data to be processed, often causing a change in state or side-effects. + */ case Post = 'POST'; - /** The PUT method replaces the target resource with the request payload. */ + /** + * The PUT method replaces the target resource with the request payload. + */ case Put = 'PUT'; - /** The PATCH method applies partial modifications to the target resource. */ + /** + * The PATCH method applies partial modifications to the target resource. + */ case Patch = 'PATCH'; - /** The DELETE method removes the specified resource. */ + /** + * The DELETE method removes the specified resource. + */ case Delete = 'DELETE'; - /** The PURGE method requests that a cached resource be removed, often used with proxy servers. */ + /** + * The PURGE method requests that a cached resource be removed, often used with proxy servers. + */ case Purge = 'PURGE'; - /** The OPTIONS method describes the communication options for the target resource. */ + /** + * The OPTIONS method describes the communication options for the target resource. + */ case Options = 'OPTIONS'; - /** The TRACE method performs a message loop-back test along the path to the target resource. */ + /** + * The TRACE method performs a message loop-back test along the path to the target resource. + */ case Trace = 'TRACE'; - /** The CONNECT method establishes a tunnel to the target resource, often used with HTTPS proxies. */ + /** + * The CONNECT method establishes a tunnel to the target resource, often used with HTTPS proxies. + */ case Connect = 'CONNECT'; /** diff --git a/src/StatusCode.php b/src/StatusCode.php index fcd6735..e38ae80 100644 --- a/src/StatusCode.php +++ b/src/StatusCode.php @@ -36,201 +36,327 @@ enum StatusCode: int { // Informational 1xx - /** @var int The server has received the request headers and the client SHOULD proceed to send the request body. */ + /** + * @var int the server has received the request headers and the client SHOULD proceed to send the request body + */ case Continue = 100; - /** @var int The requester has asked the server to switch protocols. */ + /** + * @var int the requester has asked the server to switch protocols + */ case SwitchingProtocols = 101; - /** @var int The server has received and is processing the request, but no response is available yet. */ + /** + * @var int the server has received and is processing the request, but no response is available yet + */ case Processing = 102; - /** @var int Used to return some response headers before the final HTTP message. */ + /** + * @var int used to return some response headers before the final HTTP message + */ case EarlyHints = 103; // Successful 2xx - /** @var int The request has succeeded. */ + /** + * @var int the request has succeeded + */ case Ok = 200; - /** @var int The request has been fulfilled and has resulted in the creation of a new resource. */ + /** + * @var int the request has been fulfilled and has resulted in the creation of a new resource + */ case Created = 201; - /** @var int The request has been accepted for processing, but the processing has not been completed. */ + /** + * @var int the request has been accepted for processing, but the processing has not been completed + */ case Accepted = 202; - /** @var int The request was successful, but the enclosed payload has been modified from that of the origin server. */ + /** + * @var int the request was successful, but the enclosed payload has been modified from that of the origin server + */ case NonAuthoritativeInformation = 203; - /** @var int The server successfully processed the request and is not returning any content. */ + /** + * @var int the server successfully processed the request and is not returning any content + */ case NoContent = 204; - /** @var int The server successfully processed the request, but is not returning any content, and requests that the requester reset the document view. */ + /** + * @var int the server successfully processed the request, but is not returning any content, and requests that the requester reset the document view + */ case ResetContent = 205; - /** @var int The server is delivering only part of the resource due to a range header sent by the client. */ + /** + * @var int the server is delivering only part of the resource due to a range header sent by the client + */ case PartialContent = 206; - /** @var int The message body that follows is an XML message and can contain a number of separate response codes. */ + /** + * @var int the message body that follows is an XML message and can contain a number of separate response codes + */ case MultiStatus = 207; - /** @var int The members of a DAV binding have already been enumerated in a previous reply to this request, and are not being included again. */ + /** + * @var int the members of a DAV binding have already been enumerated in a previous reply to this request, and are not being included again + */ case AlreadyReported = 208; - /** @var int The server has fulfilled a request for the resource, and the response is a representation of the result of one or more instance-manipulations. */ + /** + * @var int the server has fulfilled a request for the resource, and the response is a representation of the result of one or more instance-manipulations + */ case ImUsed = 226; // Redirection 3xx - /** @var int Indicates multiple options for the resource from which the client may choose. */ + /** + * @var int indicates multiple options for the resource from which the client may choose + */ case MultipleChoices = 300; - /** @var int This and all future requests SHOULD be directed to the given URI. */ + /** + * @var int this and all future requests SHOULD be directed to the given URI + */ case MovedPermanently = 301; - /** @var int The resource resides temporarily under a different URI. */ + /** + * @var int the resource resides temporarily under a different URI + */ case Found = 302; - /** @var int The response to the request can be found under another URI using a GET method. */ + /** + * @var int the response to the request can be found under another URI using a GET method + */ case SeeOther = 303; - /** @var int Indicates the resource has not been modified since the version specified by the request headers. */ + /** + * @var int indicates the resource has not been modified since the version specified by the request headers + */ case NotModified = 304; - /** @var int The requested resource is only available through a proxy, whose address is provided in the response. */ + /** + * @var int the requested resource is only available through a proxy, whose address is provided in the response + */ case UseProxy = 305; - /** @var int Reserved for future use. */ + /** + * @var int reserved for future use + */ case Reserved = 306; - /** @var int Instructs the client to repeat the request with a different URI. */ + /** + * @var int instructs the client to repeat the request with a different URI + */ case TemporaryRedirect = 307; - /** @var int The request and all future requests SHOULD be repeated using another URI. */ + /** + * @var int the request and all future requests SHOULD be repeated using another URI + */ case PermanentRedirect = 308; // Client Errors 4xx - /** @var int The server cannot process the request due to a client error. */ + /** + * @var int the server cannot process the request due to a client error + */ case BadRequest = 400; - /** @var int Authentication is required and has failed or has not yet been provided. */ + /** + * @var int authentication is required and has failed or has not yet been provided + */ case Unauthorized = 401; - /** @var int Payment is required to access the requested resource. */ + /** + * @var int payment is required to access the requested resource + */ case PaymentRequired = 402; - /** @var int The client does not have permission to access the resource. */ + /** + * @var int the client does not have permission to access the resource + */ case Forbidden = 403; - /** @var int The requested resource could not be found. */ + /** + * @var int the requested resource could not be found + */ case NotFound = 404; - /** @var int A request method is not supported for the requested resource. */ + /** + * @var int a request method is not supported for the requested resource + */ case MethodNotAllowed = 405; - /** @var int The requested resource is capable of generating only content not acceptable according to the Accept headers. */ + /** + * @var int the requested resource is capable of generating only content not acceptable according to the Accept headers + */ case NotAcceptable = 406; - /** @var int Proxy authentication is required to access the resource. */ + /** + * @var int proxy authentication is required to access the resource + */ case ProxyAuthenticationRequired = 407; - /** @var int The client did not produce a request within the time that the server was prepared to wait. */ + /** + * @var int the client did not produce a request within the time that the server was prepared to wait + */ case RequestTimeout = 408; - /** @var int The request could not be completed due to a conflict with the current state of the resource. */ + /** + * @var int the request could not be completed due to a conflict with the current state of the resource + */ case Conflict = 409; - /** @var int The resource requested is no longer available and will not be available again. */ + /** + * @var int the resource requested is no longer available and will not be available again + */ case Gone = 410; - /** @var int The request did not specify the length of its content, which is required by the requested resource. */ + /** + * @var int the request did not specify the length of its content, which is required by the requested resource + */ case LengthRequired = 411; - /** @var int The server does not meet one of the preconditions specified by the requester. */ + /** + * @var int the server does not meet one of the preconditions specified by the requester + */ case PreconditionFailed = 412; - /** @var int The request is larger than the server is willing or able to process. */ + /** + * @var int the request is larger than the server is willing or able to process + */ case PayloadTooLarge = 413; - /** @var int The URI provided was too long for the server to process. */ + /** + * @var int the URI provided was too long for the server to process + */ case UriTooLong = 414; - /** @var int The server does not support the media format of the requested data. */ + /** + * @var int the server does not support the media format of the requested data + */ case UnsupportedMediaType = 415; - /** @var int The client has asked for a portion of the file, but the server cannot supply that portion. */ + /** + * @var int the client has asked for a portion of the file, but the server cannot supply that portion + */ case RangeNotSatisfiable = 416; - /** @var int The server cannot meet the expectations specified in the Expect request header. */ + /** + * @var int the server cannot meet the expectations specified in the Expect request header + */ case ExpectationFailed = 417; - /** @var int This code is returned by HTCPCP-compliant teapots. */ + /** + * @var int this code is returned by HTCPCP-compliant teapots + */ case ImATeapot = 418; - /** @var int The request was directed at a server that is not able to produce a response. */ + /** + * @var int the request was directed at a server that is not able to produce a response + */ case MisdirectedRequest = 421; - /** @var int The request was well-formed but was unable to be followed due to semantic errors. */ + /** + * @var int the request was well-formed but was unable to be followed due to semantic errors + */ case UnprocessableEntity = 422; - /** @var int The resource that is being accessed is locked. */ + /** + * @var int the resource that is being accessed is locked + */ case Locked = 423; - /** @var int The request failed due to failure of a previous request. */ + /** + * @var int the request failed due to failure of a previous request + */ case FailedDependency = 424; - /** @var int Indicates the server is unwilling to risk processing a request that might be replayed. */ + /** + * @var int indicates the server is unwilling to risk processing a request that might be replayed + */ case TooEarly = 425; - /** @var int The client should switch to a different protocol such as TLS/1.0. */ + /** + * @var int The client should switch to a different protocol such as TLS/1.0. + */ case UpgradeRequired = 426; - /** @var int The origin server requires the request to be conditional. */ + /** + * @var int the origin server requires the request to be conditional + */ case PreconditionRequired = 428; - /** @var int The user has sent too many requests in a given amount of time. */ + /** + * @var int the user has sent too many requests in a given amount of time + */ case TooManyRequests = 429; - /** @var int The server is unwilling to process the request because its header fields are too large. */ + /** + * @var int the server is unwilling to process the request because its header fields are too large + */ case RequestHeaderFieldsTooLarge = 431; - /** @var int The requested resource is unavailable for legal reasons. */ + /** + * @var int the requested resource is unavailable for legal reasons + */ case UnavailableForLegalReasons = 451; // Server Errors 5xx - /** @var int The server encountered an unexpected condition that prevented it from fulfilling the request. */ + /** + * @var int the server encountered an unexpected condition that prevented it from fulfilling the request + */ case InternalServerError = 500; - /** @var int The server does not support the functionality required to fulfill the request. */ + /** + * @var int the server does not support the functionality required to fulfill the request + */ case NotImplemented = 501; - /** @var int The server, while acting as a gateway or proxy, received an invalid response from the upstream server. */ + /** + * @var int the server, while acting as a gateway or proxy, received an invalid response from the upstream server + */ case BadGateway = 502; - /** @var int The server is currently unavailable due to maintenance or overload. */ + /** + * @var int the server is currently unavailable due to maintenance or overload + */ case ServiceUnavailable = 503; - /** @var int The server did not receive a timely response from an upstream server. */ + /** + * @var int the server did not receive a timely response from an upstream server + */ case GatewayTimeout = 504; - /** @var int The server does not support the HTTP protocol version used in the request. */ + /** + * @var int the server does not support the HTTP protocol version used in the request + */ case VersionNotSupported = 505; - /** @var int Transparent content negotiation for the request results in a circular reference. */ + /** + * @var int transparent content negotiation for the request results in a circular reference + */ case VariantAlsoNegotiates = 506; - /** @var int The server is unable to store the representation needed to complete the request. */ + /** + * @var int the server is unable to store the representation needed to complete the request + */ case InsufficientStorage = 507; - /** @var int The server detected an infinite loop while processing a request. */ + /** + * @var int the server detected an infinite loop while processing a request + */ case LoopDetected = 508; - /** @var int Further extensions to the request are required for the server to fulfill it. */ + /** + * @var int further extensions to the request are required for the server to fulfill it + */ case NotExtended = 510; - /** @var int The client needs to authenticate to gain network access. */ + /** + * @var int the client needs to authenticate to gain network access + */ case NetworkAuthenticationRequired = 511; /** diff --git a/tests/Header/AcceptTest.php b/tests/Header/AcceptTest.php index 46593ce..b0cfcc0 100644 --- a/tests/Header/AcceptTest.php +++ b/tests/Header/AcceptTest.php @@ -69,23 +69,71 @@ public static function providerBestMatch(): array $allSupported = [Accept::ApplicationJson, Accept::ApplicationXml, Accept::TextHtml]; return [ - 'legacy extension does not override quality' => ['text/html;q=0.5;note="a;q=0", application/json;q=0.2', $allSupported, Accept::TextHtml], - 'escaped quote in other range does not suppress json' => ['application/json, text/html;profile="a\"b"', [Accept::ApplicationJson], Accept::ApplicationJson], - 'escaped backslash before closing quote' => ['text/html;profile="a\\\\", application/json', [Accept::ApplicationJson], Accept::ApplicationJson], + 'legacy extension does not override quality' => [ + 'text/html;q=0.5;note="a;q=0", application/json;q=0.2', + $allSupported, + Accept::TextHtml, + ], + 'escaped quote in other range does not suppress json' => [ + 'application/json, text/html;profile="a\"b"', + [Accept::ApplicationJson], + Accept::ApplicationJson, + ], + 'escaped backslash before closing quote' => [ + 'text/html;profile="a\\\\", application/json', + [Accept::ApplicationJson], + Accept::ApplicationJson, + ], 'uppercase media type' => ['APPLICATION/JSON', $allSupported, Accept::ApplicationJson], - 'equal quality favors specific representation' => ['*/*;q=0.5, text/html;q=0.5', $allSupported, Accept::TextHtml], - 'quoted comma parameter is not a new range' => ['text/html;profile="a,application/json"', $allSupported, null], + 'equal quality favors specific representation' => [ + '*/*;q=0.5, text/html;q=0.5', + $allSupported, + Accept::TextHtml, + ], + 'quoted comma parameter is not a new range' => [ + 'text/html;profile="a,application/json"', + $allSupported, + null, + ], 'zero excluded' => ['application/json;q=0', $allSupported, null], - 'specific exclusion beats wildcard' => ['application/json;q=0, */*;q=1', [Accept::ApplicationJson, Accept::TextHtml], Accept::TextHtml], - 'specific quality beats wildcard quality' => ['text/*;q=1, text/plain;q=0.1, application/json;q=0.5', [Accept::TextPlain, Accept::ApplicationJson], Accept::ApplicationJson], - 'case insensitive type and quality name' => ['APPLICATION/JSON;Q=0, TEXT/HTML', $allSupported, Accept::TextHtml], + 'specific exclusion beats wildcard' => [ + 'application/json;q=0, */*;q=1', + [Accept::ApplicationJson, Accept::TextHtml], + Accept::TextHtml, + ], + 'specific quality beats wildcard quality' => [ + 'text/*;q=1, text/plain;q=0.1, application/json;q=0.5', + [Accept::TextPlain, Accept::ApplicationJson], + Accept::ApplicationJson, + ], + 'case insensitive type and quality name' => [ + 'APPLICATION/JSON;Q=0, TEXT/HTML', + $allSupported, + Accept::TextHtml, + ], 'wildcard exclusion' => ['*/*;q=0', $allSupported, null], - 'specific acceptance overrides wildcard exclusion' => ['*/*;q=0, application/json;q=0.5', $allSupported, Accept::ApplicationJson], + 'specific acceptance overrides wildcard exclusion' => [ + '*/*;q=0, application/json;q=0.5', + $allSupported, + Accept::ApplicationJson, + ], 'parameterized range does not match bare representation' => ['text/html;level=1', [Accept::TextHtml], null], - 'parameterized exclusion does not exclude bare representation' => ['text/html;level=1;q=0, text/html;q=1', [Accept::TextHtml], Accept::TextHtml], - 'invalid quality above one skips range' => ['application/json;q=2, text/html;q=0.5', $allSupported, Accept::TextHtml], + 'parameterized exclusion does not exclude bare representation' => [ + 'text/html;level=1;q=0, text/html;q=1', + [Accept::TextHtml], + Accept::TextHtml, + ], + 'invalid quality above one skips range' => [ + 'application/json;q=2, text/html;q=0.5', + $allSupported, + Accept::TextHtml, + ], 'non numeric quality skips range' => ['application/json;q=abc', $allSupported, null], - 'padded quality competes with higher quality' => ["\ttext/html ; Q=0.5, application/json;q=0.8", $allSupported, Accept::ApplicationJson], + 'padded quality competes with higher quality' => [ + "\ttext/html ; Q=0.5, application/json;q=0.8", + $allSupported, + Accept::ApplicationJson, + ], 'empty header' => ['', $allSupported, null], 'simple match: json' => ['application/json', $allSupported, Accept::ApplicationJson], 'simple match: html' => ['text/html', $allSupported, Accept::TextHtml], diff --git a/tests/Header/AuthorizationTest.php b/tests/Header/AuthorizationTest.php index 8e45b2c..1087fc1 100644 --- a/tests/Header/AuthorizationTest.php +++ b/tests/Header/AuthorizationTest.php @@ -135,6 +135,7 @@ public function testSchemeCaseDoesNotChangeCredentials(string $header, ?array $e { if (null === $expected) { self::assertNull(Authorization::parse($header)); + return; } @@ -149,6 +150,11 @@ public function testSchemeCaseDoesNotChangeCredentials(string $header, ?array $e } } + /** + * Supplies authorization headers and their expected parsed credentials. + * + * @return array + */ public static function providerParse(): array { return [ diff --git a/tests/Header/ContentEncodingTest.php b/tests/Header/ContentEncodingTest.php index 1f2f4eb..de2b7f9 100644 --- a/tests/Header/ContentEncodingTest.php +++ b/tests/Header/ContentEncodingTest.php @@ -105,7 +105,11 @@ public static function providerIsSupported(): array 'missing header allows compression' => [ContentEncoding::Gzip, null, true], 'empty header accepts identity' => [ContentEncoding::Identity, '', true], 'wildcard rejects identity' => [ContentEncoding::Identity, '*;q=0', false], - 'explicit identity overrides wildcard rejection' => [ContentEncoding::Identity, 'identity;q=0.5, *;q=0', true], + 'explicit identity overrides wildcard rejection' => [ + ContentEncoding::Identity, + 'identity;q=0.5, *;q=0', + true, + ], 'explicit exclusion overrides wildcard acceptance' => [ContentEncoding::Gzip, 'gzip;q=0, *;q=1', false], 'case insensitive quality name' => [ContentEncoding::Gzip, 'GZIP;Q=0', false], 'wildcard only' => [ContentEncoding::Zstd, '*', true], diff --git a/tests/HeaderRuntimeCompatibilityTest.php b/tests/HeaderRuntimeCompatibilityTest.php index ea9bf19..30ef095 100644 --- a/tests/HeaderRuntimeCompatibilityTest.php +++ b/tests/HeaderRuntimeCompatibilityTest.php @@ -10,14 +10,22 @@ #[CoversNothing] final class HeaderRuntimeCompatibilityTest extends TestCase { + /** + * Verifies header parsing in an isolated production-style PHP process. + * + * @return void + */ public function testHeadersWithoutDevelopmentPolyfillsOrMbstringFunctions(): void { $process = proc_open([ - PHP_BINARY, + \PHP_BINARY, '-d', 'disable_functions=mb_trim,mb_strtolower', __DIR__ . '/fixtures/header-runtime.php', - ], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + ], [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); self::assertIsResource($process); $output = stream_get_contents($pipes[1]); diff --git a/tests/JsonResponseTest.php b/tests/JsonResponseTest.php index 107f6c5..e1544c9 100644 --- a/tests/JsonResponseTest.php +++ b/tests/JsonResponseTest.php @@ -18,16 +18,20 @@ namespace FastForward\Http\Message\Tests; -use PHPUnit\Framework\Attributes\Test; use FastForward\Http\Message\Header\ContentType; use FastForward\Http\Message\JsonResponse; use FastForward\Http\Message\JsonStream; use FastForward\Http\Message\PayloadResponseInterface; use FastForward\Http\Message\PayloadStreamInterface; +use JsonException; +use Nyholm\Psr7\Stream; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\StreamInterface; +use RuntimeException; /** * @internal @@ -132,76 +136,134 @@ public static function providerCharsets(): array ]; } + /** + * Decodes replacement JSON while preserving the stream cursor and identity. + * + * @return void + */ #[Test] public function genericJsonBodyIsDecodedWithoutMovingItsCursor(): void { - $original = new JsonResponse(['original' => true]); - $stream = \Nyholm\Psr7\Stream::create('{"replacement":true}'); + $original = new JsonResponse([ + 'original' => true, + ]); + $stream = Stream::create('{"replacement":true}'); $stream->seek(5); + $replacement = $original->withBody($stream); - self::assertSame(['replacement' => true], $replacement->getPayload()); + self::assertSame([ + 'replacement' => true, + ], $replacement->getPayload()); self::assertSame(5, $stream->tell()); - self::assertSame(['original' => true], $original->getPayload()); - $updated = $replacement->withPayload(['next' => true]); - self::assertSame(['next' => true], $updated->getPayload()); + self::assertSame([ + 'original' => true, + ], $original->getPayload()); + $updated = $replacement->withPayload([ + 'next' => true, + ]); + self::assertSame([ + 'next' => true, + ], $updated->getPayload()); self::assertSame($stream, $replacement->getBody()); } + /** + * Preserves the cursor on invalid JSON and permits replacing the payload. + * + * @return void + */ #[Test] public function invalidGenericBodyHasAnExplicitJsonErrorAndRemainsReplaceable(): void { - $stream = \Nyholm\Psr7\Stream::create('not json'); + $stream = Stream::create('not json'); $stream->seek(2); + $response = (new JsonResponse())->withBody($stream); - self::assertSame(['recovered' => true], $response->withPayload(['recovered' => true])->getPayload()); + self::assertSame([ + 'recovered' => true, + ], $response->withPayload([ + 'recovered' => true, + ])->getPayload()); try { $response->getPayload(); self::fail('Invalid JSON must not be silently accepted.'); - } catch (\JsonException) { + } catch (JsonException) { self::assertSame(2, $stream->tell()); } } + /** + * Reports a detached stream and permits replacing its payload. + * + * @return void + */ #[Test] public function detachedGenericBodyHasAnExplicitStreamErrorAndRemainsReplaceable(): void { - $stream = \Nyholm\Psr7\Stream::create('{}'); + $stream = Stream::create('{}'); fclose($stream->detach()); $response = (new JsonResponse())->withBody($stream); self::assertSame('recovered', $response->withPayload('recovered')->getPayload()); - $this->expectException(\RuntimeException::class); + $this->expectException(RuntimeException::class); $response->getPayload(); } + /** + * Decodes a valid JSON null without treating it as a parse error. + * + * @return void + */ #[Test] public function genericJsonNullIsNotConfusedWithInvalidJson(): void { - $response = (new JsonResponse())->withBody(\Nyholm\Psr7\Stream::create('null')); + $response = (new JsonResponse())->withBody(Stream::create('null')); self::assertNull($response->getPayload()); } + /** + * Rejects a non-seekable body without consuming it. + * + * @return void + */ #[Test] public function nonSeekableGenericBodyIsNotConsumed(): void { - $body = $this->createMock(\Psr\Http\Message\StreamInterface::class); - $body->method('isReadable')->willReturn(true); - $body->method('isSeekable')->willReturn(false); + $body = $this->createMock(StreamInterface::class); + $body->method('isReadable') + ->willReturn(true); + $body->method('isSeekable') + ->willReturn(false); $body->expects(self::never())->method('getContents'); $response = (new JsonResponse())->withBody($body); - self::assertSame(['new' => true], $response->withPayload(['new' => true])->getPayload()); - $this->expectException(\RuntimeException::class); + self::assertSame([ + 'new' => true, + ], $response->withPayload([ + 'new' => true, + ])->getPayload()); + $this->expectException(RuntimeException::class); $response->getPayload(); } + /** + * Preserves stream encoding options and headers when replacing the payload. + * + * @return void + */ #[Test] public function payloadReplacementPreservesExistingJsonStreamEncodingOptionsAndHeaders(): void { - $stream = new \FastForward\Http\Message\JsonStream(['old' => true], \JSON_PRETTY_PRINT); + $stream = new JsonStream([ + 'old' => true, + ], \JSON_PRETTY_PRINT); $response = (new JsonResponse([], 'iso-8859-1'))->withBody($stream); - $updated = $response->withPayload(['new' => true]); + $updated = $response->withPayload([ + 'new' => true, + ]); self::assertStringContainsString("\n", (string) $updated->getBody()); self::assertSame($response->getHeaders(), $updated->getHeaders()); - self::assertSame(['old' => true], $response->getPayload()); + self::assertSame([ + 'old' => true, + ], $response->getPayload()); } } diff --git a/tests/fixtures/header-runtime.php b/tests/fixtures/header-runtime.php index 6379c42..d93f82b 100644 --- a/tests/fixtures/header-runtime.php +++ b/tests/fixtures/header-runtime.php @@ -26,25 +26,37 @@ } $checks = [ - 'Accept' => static fn (): bool => Accept::ApplicationJson === Accept::getBestMatch(" \tapplication/json\t ", [Accept::ApplicationJson]), - 'ContentType' => static fn (): bool => 'UTF-8' === ContentType::getCharset("text/plain; charset= \tUTF-8\t "), - 'ContentEncoding' => static fn (): bool => ContentEncoding::isSupported(ContentEncoding::Gzip, 'GZIP;q=1') + 'Accept' => static fn(): bool => Accept::ApplicationJson === Accept::getBestMatch( + " \tapplication/json\t ", + [Accept::ApplicationJson] + ), + 'ContentType' => static fn(): bool => 'UTF-8' === ContentType::getCharset("text/plain; charset= \tUTF-8\t "), + 'ContentEncoding' => static fn(): bool => ContentEncoding::isSupported(ContentEncoding::Gzip, 'GZIP;q=1') && ! ContentEncoding::isSupported(ContentEncoding::Gzip, 'GZIP;q=0'), - 'TransferEncoding' => static fn (): bool => TransferEncoding::isChunked("GZIP, \tCHUNKED\t "), + 'TransferEncoding' => static fn(): bool => TransferEncoding::isChunked("GZIP, \tCHUNKED\t "), 'Digest' => static function (): bool { $value = "\u{00A0}Élodie\xFF\u{00A0}"; - $credential = Authorization::parse('Digest USERNAME="' . $value . '", realm="Example", nonce="ABC", uri="/Path", response="ABC", qop="auth", nc="00000001", cnonce="XYZ"'); + $credential = Authorization::parse( + 'Digest USERNAME="' . $value . '", realm="Example", nonce="ABC", uri="/Path", response="ABC", qop="auth", nc="00000001", cnonce="XYZ"' + ); + return $credential instanceof DigestCredential && $value === $credential->username && 'Example' === $credential->realm && 'ABC' === $credential->nonce && '/Path' === $credential->uri; }, 'AWS' => static function (): bool { $scope = "KEY\u{00A0}/20261021/Region/Service/aws4_request"; $signature = str_repeat('Ab', 32); - $credential = Authorization::parse('AWS4-HMAC-SHA256 Credential=' . $scope . ', SignedHeaders=host;x-test, Signature=' . $signature); + $credential = Authorization::parse( + 'AWS4-HMAC-SHA256 Credential=' . $scope . ', SignedHeaders=host;x-test, Signature=' . $signature + ); + return $credential instanceof AwsCredential && $scope === $credential->credentialScope && $signature === $credential->signature && 'host;x-test' === $credential->signedHeaders; }, - 'Unicode is not OWS' => static fn (): bool => null === Accept::getBestMatch("\u{00A0}application/json\u{00A0}", [Accept::ApplicationJson]) + 'Unicode is not OWS' => static fn(): bool => ! Accept::getBestMatch( + "\u{00A0}application/json\u{00A0}", + [Accept::ApplicationJson] + ) instanceof Accept && ! TransferEncoding::isChunked("\u{00A0}chunked\u{00A0}") && "\u{00A0}UTF-8\u{00A0}" === ContentType::getCharset("text/plain; charset=\u{00A0}UTF-8\u{00A0}"), ]; @@ -55,11 +67,13 @@ if (! $check()) { throw new RuntimeException('Unexpected result'); } + echo $name . ": ok\n"; } catch (Throwable $error) { // Fixtures contain synthetic data only; never print credential values. - fwrite(STDERR, $name . ': ' . $error->getMessage() . "\n"); + fwrite(\STDERR, $name . ': ' . $error->getMessage() . "\n"); $failed = true; } } + exit($failed ? 1 : 0);