From b7fca532e5ba8abfe2f20acc5d82dbccd4a0c306 Mon Sep 17 00:00:00 2001 From: joramkruijer Date: Wed, 23 Sep 2026 19:25:19 +0200 Subject: [PATCH 1/4] feat: response headers, settable timeouts, strict typing, php8 language constructs --- composer.json | 4 +- src/Client.php | 262 +++++++++++++++------- src/Exceptions/Exception.php | 2 + src/Exceptions/RequestFailedException.php | 28 ++- 4 files changed, 209 insertions(+), 87 deletions(-) diff --git a/composer.json b/composer.json index 580ee51..670b1dc 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "license": "LGPL-2.1-only", "description": "NOva REST for the wicked", "type": "library", - "version": "1.2.0", + "version": "2.0.0", "authors": [ { "name": "Jur van den Berg", @@ -24,7 +24,7 @@ } }, "require": { - "php": ">= 7.3", + "php": ">= 8.1", "ext-curl": "*", "ext-json": "*" }, diff --git a/src/Client.php b/src/Client.php index 8048119..b54f390 100644 --- a/src/Client.php +++ b/src/Client.php @@ -1,7 +1,9 @@ as per RFC 7230 */ - private $headers; + private array $headers; /** * Currently cached curl headers * This is a cache created from calling Client::collapseHeaders and should not be used * * @internal - * @var array + * @var string[] as per RFC 7230 */ - private $curlHeaders = []; + private array $curlHeaders = []; + + private int $timeout; + + private int $connectTimeout; + + /** + * The headers received in the most recent response made by this instance, keyed by + * lowercased header name with a list of values. + * + * RFC 7230 section 3.2.2 only allows a header field to repeat when its value is a + * comma-separated list, or for well-known exceptions such as Set-Cookie; a compliant + * server should otherwise send it at most once. We still capture every occurrence as a + * list rather than assume compliance, since misconfigured servers and proxies do send + * duplicates regardless. + * + * Null until a request has been made on this instance. + * + * @var array|null + */ + private ?array $lastResponseHeaders = null; /** * Client constructor. - * If no headers are passed, the default content type is set to - * application/json + * If no 'content-type' header is passed, it defaults to application/json. * @param string $base The base URL for making requests * @param array|null $headers * @param int[] $jsonFlags Options to pass to json_encode and json_decode. Used only if content-type is * 'application/json' + * @param int $timeout Total request timeout, in seconds + * @param int $connectTimeout Connection timeout, in seconds */ - public function __construct(string $base, ?array $headers = null, array $jsonFlags = []) - { + public function __construct( + string $base, + ?array $headers = null, + array $jsonFlags = [], + int $timeout = self::DEFAULT_TIMEOUT, + int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT + ) { $this->baseUrl = $base; - if ($headers === null) { - $headers = [ - self::CONTENT_TYPE_HEADER => 'application/json' - ]; - } + $headers ??= []; + // No header provided, default to 'application/json' + $headers += [self::CONTENT_TYPE_HEADER => 'application/json']; $this->headers = $headers; $this->jsonFlags = array_unique($jsonFlags); + $this->timeout = $timeout; + $this->connectTimeout = $connectTimeout; } /** @@ -104,7 +146,7 @@ public function __construct(string $base, ?array $headers = null, array $jsonFla */ public function setBaseUrl(string $base): self { - return new self($base, $this->headers, $this->jsonFlags); + return new self($base, $this->headers, $this->jsonFlags, $this->timeout, $this->connectTimeout); } /** @@ -117,7 +159,7 @@ public function setContentType(string $type): self { $type = strtolower($type); if (!in_array($type, self::SUPPORTED_CONTENT_TYPES, true)) { - throw new \InvalidArgumentException("Unsupported content type: {$type}"); + throw new InvalidArgumentException("Unsupported content type: {$type}"); } return $this->addHeader(self::CONTENT_TYPE_HEADER, $type); } @@ -131,10 +173,10 @@ public function setContentType(string $type): self * * @param string $header Header key * @param string $value Header value - * @param array $options Key/Value options set + * @param array $options Key/Value options set * - nolowercase: do not call strtolower on the header. * Should only be used for API's that do not function without it, since it is in direct contravention - * of RFC 2616 section 4.2 which states that all HTTP header field names "[...] are case-insensitive." + * of RFC 7230 section 3.2 which states that all HTTP header field names "[...] a case-insensitive field name." * * @return Client new Client instance with that header added. * @@ -151,7 +193,7 @@ public function addHeader(string $header, string $value, array $options = []): s } $newHeaders[$header] = $value; - return new self($this->baseUrl, $newHeaders, $this->jsonFlags); + return new self($this->baseUrl, $newHeaders, $this->jsonFlags, $this->timeout, $this->connectTimeout); } /** @@ -162,7 +204,36 @@ public function addHeader(string $header, string $value, array $options = []): s */ public function setJsonFlags(array $flags): self { - return new self($this->baseUrl, $this->headers, $flags); + return new self($this->baseUrl, $this->headers, $flags, $this->timeout, $this->connectTimeout); + } + + /** + * Set the connection and total request timeouts. Returns a new instance. + * + * @param int $timeout Total request timeout, in seconds + * @param int|null $connectTimeout Connection timeout, in seconds. Defaults to self::DEFAULT_CONNECT_TIMEOUT + * @return Client new instance with the new timeouts + */ + public function setTimeout(int $timeout, ?int $connectTimeout = null): self + { + return new self( + $this->baseUrl, + $this->headers, + $this->jsonFlags, + $timeout, + $connectTimeout ?? $this->connectTimeout + ); + } + + /** + * Get the headers of the most recently received response for a request made on this instance. + * Returns null if no request has been made on this instance yet. + * + * @return array|null + */ + public function getLastResponseHeaders(): ?array + { + return $this->lastResponseHeaders; } /** @@ -173,9 +244,9 @@ public function setJsonFlags(array $flags): self * and be especially careful when handling strings, since PHP makes no difference between a well-formed string and * binary data returned such as with image data. * @throws RequestFailedException If the request fails for any reason - * @throws \JsonException Only if JSON decoding fails + * @throws JsonException Only if JSON decoding fails */ - public function get(string $url) + public function get(string $url): mixed { return $this->sendRequest($url, 'GET'); } @@ -186,14 +257,14 @@ public function get(string $url) * * @param string $url URL * @param string $method HTTP method - * @param mixed $payload Body + * @param mixed|null $payload Body * @return mixed Decoded data according to what the server returns. Do not make any assumptions on this data * and be especially careful when handling strings, since PHP makes no difference between a well-formed string and * binary data returned such as with image data. - * @throws RequestFailedException|\JsonException + * @throws RequestFailedException|JsonException * @internal Please use Client::get(), Client::post() et al. */ - protected function sendRequest(string $url, string $method, $payload = null) + protected function sendRequest(string $url, string $method, mixed $payload = null): mixed { $method = strtoupper($method); $combinedUrl = $this->combineUrl($url); @@ -203,19 +274,21 @@ protected function sendRequest(string $url, string $method, $payload = null) CURLOPT_HEADER => false, CURLOPT_RETURNTRANSFER => true, CURLOPT_ENCODING => '', + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_CONNECTTIMEOUT => $this->connectTimeout, ]; if ($method === 'POST') { if (!is_array($payload) && !is_object($payload) && !is_string($payload)) { - throw new \InvalidArgumentException('Client: Missing or invalid payload'); + throw new InvalidArgumentException('Client: Missing or invalid payload'); } // If we are sending multipart/form-data, don't encode our body. If the data // passed to cURL is an array the content type will be set to multipart/form-data // automatically and passed files will be automatically processed. - if ($this->headers[self::CONTENT_TYPE_HEADER] === 'multipart/form-data') { + if ($this->getContentType() === 'multipart/form-data') { if (!is_array($payload)) { - throw new \InvalidArgumentException("Client: payload for multipart/form-data must be an array"); + throw new InvalidArgumentException("Client: payload for multipart/form-data must be an array"); } $data = $payload; @@ -229,7 +302,7 @@ protected function sendRequest(string $url, string $method, $payload = null) ]; } elseif ($method === 'PUT' || $method === 'PATCH') { if (!is_array($payload) && !is_object($payload) && !is_string($payload)) { - throw new \InvalidArgumentException('Client: Missing or invalid payload'); + throw new InvalidArgumentException('Client: Missing or invalid payload'); } $curlOptions += [ @@ -271,11 +344,19 @@ static function ($curl, $header) use (&$responseHeaders) { ]; $curlHandle = curl_init(); + if ($curlHandle === false) { + throw new RequestFailedException('Curl error: unable to initialize a cURL handle'); + } curl_setopt_array($curlHandle, $curlOptions); $responseBody = curl_exec($curlHandle); - if (curl_errno($curlHandle)) { + // curl_exec returns string on success or false on failure, as CURLOPT_RETURNTRANSFER is set to true. + // The curl_errno check is intentionally redundant defense-in-depth: both (currently) read the same + // underlying CURLcode of this curl_exec call, so they shouldn't disagree (verified empirically on PHP8.4, + // incl. for CURLE_PARTIAL_FILE), but let's double-check to prevent decoding a broken or truncated response. + if (!is_string($responseBody) || curl_errno($curlHandle) !== 0) { $this->handleCurlError($curlHandle); } + $this->lastResponseHeaders = $responseHeaders; if (array_key_exists(self::CONTENT_TYPE_HEADER, $responseHeaders)) { $responseBody = $this->decodeBody($responseBody, $responseHeaders[self::CONTENT_TYPE_HEADER]); @@ -287,31 +368,49 @@ static function ($curl, $header) use (&$responseHeaders) { curl_close($curlHandle); if ($responseCode < 200 || $responseCode > 299) { - $this->handleResponseError($responseCode, $responseBody); + $this->handleResponseError($responseCode, $responseBody, $responseHeaders); } return $responseBody; } /** - * Encodes the request body to the content type specified in the 'Content-Type' header + * Encodes the request body to the content type specified in the 'Content-Type' header. + * A string payload is always considered already-encoded and is sent as-is, regardless of + * content type. Any other payload is encoded according to the content type. * * @param mixed $body Body to encode * @return string encoded body - * @throws \JsonException + * @throws JsonException */ - private function encodeBody($body): string + private function encodeBody(mixed $body): string { - switch ($this->headers[self::CONTENT_TYPE_HEADER]) { + $contentType = $this->getContentType(); + + switch ($contentType) { case 'application/json': + if (is_string($body)) { + return $body; + } return json_encode($body, JSON_THROW_ON_ERROR | $this->reduceFlags($this->jsonFlags), 512); case 'application/atom+xml': case 'application/xml': case 'text/plain': + if (!is_string($body)) { + throw new InvalidArgumentException("Client: payload for {$contentType} must be a string"); + } return $body; case 'application/x-www-form-urlencoded': + if (is_string($body)) { + return $body; + } + if (!is_array($body) && !is_object($body)) { + throw new InvalidArgumentException( + "Client: payload for {$contentType} must be a string, array or object" + ); + } return http_build_query($body); default: - throw new \InvalidArgumentException("Unsupported content type: {$this->headers[self::CONTENT_TYPE_HEADER]}"); + throw new InvalidArgumentException("Unsupported content type: {$contentType}"); } } @@ -333,6 +432,16 @@ static function ($carry, $flag) { ); } + /** + * The currently active 'content-type' header. + * This is always present, either set explicitly or defaulted to in the constructor. + */ + private function getContentType(): string + { + return $this->headers[self::CONTENT_TYPE_HEADER] + ?? throw new LogicException('Client: missing content-type header, this should be unreachable'); + } + /** * Collapse our key/value pair headers into curl accepted strings. * Note that this function maintains an internal cache of these headers and @@ -364,7 +473,7 @@ private function combineUrl(string $uri): string { // Check if $uri is a full uri $parsed = parse_url($uri); - if ($parsed !== null && isset($parsed['host'])) { + if ($parsed !== false && isset($parsed['host'])) { return $uri; } @@ -377,10 +486,10 @@ private function combineUrl(string $uri): string /** * Generates a CURL error message if a request fails. * - * @param resource $curlHandle CURL handle + * @param CurlHandle $curlHandle CURL handle * @throws RequestFailedException The exception for the CURL error */ - private function handleCurlError($curlHandle): void + private function handleCurlError(CurlHandle $curlHandle): never { $errorMessage = 'Curl error: ' . curl_error($curlHandle); throw new RequestFailedException($errorMessage, curl_errno($curlHandle)); @@ -388,14 +497,16 @@ private function handleCurlError($curlHandle): void /** * Decodes the response body to either the content type as given in the response header, - * or the previously specified content type + * or the previously specified content type. + * Only JSON (including vendor "+json" variants) and form-urlencoded bodies are actively + * decoded; every other content type (xml, plain text, binary, ...) is returned as-is. * * @param string $body Response body - * @param array | bool $response_header the content-type header of the response + * @param string[]|null $response_header the content-type header of the response * @return mixed decoded body - * @throws \JsonException + * @throws JsonException */ - private function decodeBody(string $body, $response_header = false) + private function decodeBody(string $body, ?array $response_header = null): mixed { if (empty($body)) { return $body; @@ -403,38 +514,26 @@ private function decodeBody(string $body, $response_header = false) // Check if the response passed a return MIME type. If not, assume the // response MIME type is the exact same as our request MIME type - if ($response_header && count($response_header) === 1) { + if ($response_header !== null && count($response_header) === 1) { $header = $response_header[0]; } else { - $header = $this->headers[self::CONTENT_TYPE_HEADER]; + $header = $this->getContentType(); } // MIME types can have annoying additions such as text/json;encoding=utf-8 // Strip such extensions from our MIME string - $mime = explode(';', $header, 2)[0]; + $mime = strtolower(explode(';', $header, 2)[0]); - switch (strtolower($mime)) { - case 'application/hal+json': - case 'application/json': - case 'text/json': - return json_decode($body, true, 512, JSON_THROW_ON_ERROR | $this->reduceFlags($this->jsonFlags)); - case 'application/x-www-form-urlencoded': - $result = []; - parse_str($body, $result); - return $result; - case 'application/atom+xml': - case 'application/xml': - case 'text/xml': - case 'application/pdf': - case 'image/png': - case 'image/jpg': - case 'image/jpeg': - case 'text/plain': - case 'text/html': - case 'text/csv': - return $body; - default: - throw new \InvalidArgumentException('Unsupported content type in Client\decodeBody: ' . $header); + if ($mime === 'application/x-www-form-urlencoded') { + $result = []; + parse_str($body, $result); + return $result; } + + if ($mime === 'application/json' || $mime === 'text/json' || str_ends_with($mime, '+json')) { + return json_decode($body, true, 512, JSON_THROW_ON_ERROR | $this->reduceFlags($this->jsonFlags)); + } + + return $body; } /** @@ -442,12 +541,13 @@ private function decodeBody(string $body, $response_header = false) * * @param int $responseCode HTTP response code * @param mixed $responseBody The body that was returned + * @param array $responseHeaders The headers that were returned * @throws RequestFailedException The exception for the response */ - private function handleResponseError(int $responseCode, $responseBody): void + private function handleResponseError(int $responseCode, mixed $responseBody, array $responseHeaders): void { $errorMessage = 'Unknown error: ' . $responseCode; - throw new RequestFailedException($errorMessage, $responseCode, $responseBody); + throw new RequestFailedException($errorMessage, $responseCode, $responseBody, $responseHeaders); } /** @@ -458,9 +558,9 @@ private function handleResponseError(int $responseCode, $responseBody): void * and be especially careful when handling strings, since PHP makes no difference between a well-formed string and * binary data returned such as with image data. * @throws RequestFailedException If the request fails for any reason - * @throws \JsonException + * @throws JsonException */ - public function delete(string $url) + public function delete(string $url): mixed { return $this->sendRequest($url, 'DELETE'); } @@ -474,9 +574,9 @@ public function delete(string $url) * and be especially careful when handling strings, since PHP makes no difference between a well-formed string and * binary data returned such as with image data. * @throws RequestFailedException If the request fails for any reason - * @throws \JsonException + * @throws JsonException */ - public function put(string $url, $payload) + public function put(string $url, mixed $payload): mixed { return $this->sendRequest($url, 'PUT', $payload); } @@ -490,9 +590,9 @@ public function put(string $url, $payload) * and be especially careful when handling strings, since PHP makes no difference between a well-formed string and * binary data returned such as with image data. * @throws RequestFailedException If the request fails for any reason - * @throws \JsonException + * @throws JsonException */ - public function post(string $url, $payload) + public function post(string $url, mixed $payload): mixed { return $this->sendRequest($url, 'POST', $payload); } @@ -506,9 +606,9 @@ public function post(string $url, $payload) * and be especially careful when handling strings, since PHP makes no difference between a well-formed string and * binary data returned such as with image data. * @throws RequestFailedException If the request fails for any reason - * @throws \JsonException + * @throws JsonException */ - public function patch(string $url, $payload) + public function patch(string $url, mixed $payload): mixed { return $this->sendRequest($url, 'PATCH', $payload); } diff --git a/src/Exceptions/Exception.php b/src/Exceptions/Exception.php index 3bd7bd1..a9ab490 100644 --- a/src/Exceptions/Exception.php +++ b/src/Exceptions/Exception.php @@ -1,4 +1,6 @@ + */ + private array $responseHeaders; - public function __construct($message = '', $code = 0, $body = null) + /** + * @param array $responseHeaders The headers of the response that caused this exception. + * Empty when the request failed completely, like on a connection error + */ + public function __construct(string $message = '', int $code = 0, mixed $body = null, array $responseHeaders = []) { parent::__construct($message, $code); $this->body = $body; + $this->responseHeaders = $responseHeaders; } - public function getBody() + public function getBody(): mixed { return $this->body; } + + /** + * @return array + */ + public function getResponseHeaders(): array + { + return $this->responseHeaders; + } } From 02b14be3905f59fca3fa03aa744ac25ce64d10a6 Mon Sep 17 00:00:00 2001 From: joramkruijer Date: Wed, 23 Sep 2026 20:12:45 +0200 Subject: [PATCH 2/4] ci: add phpstan, phpcs checking against php8.1 and php8.5 --- .github/workflows/code-quality.yml | 56 ++++++++ composer.json | 2 + composer.lock | 161 +++++++++++++++++++++- phpcs.xml | 20 +++ phpstan.neon | 4 + src/Client.php | 3 +- src/Exceptions/Exception.php | 6 +- src/Exceptions/RequestFailedException.php | 3 +- 8 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/code-quality.yml create mode 100644 phpcs.xml create mode 100644 phpstan.neon diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..dee827f --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,56 @@ +name: Code Quality + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +jobs: + phpstan: + name: PHPStan (PHP ${{ matrix.php-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['8.1', '8.5'] + continue-on-error: ${{ matrix.php-version == '8.5' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --configuration=phpstan.neon --no-progress --error-format=github + + phpcs: + name: PHP_CodeSniffer (PHP ${{ matrix.php-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['8.1', '8.5'] + continue-on-error: ${{ matrix.php-version == '8.5' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run PHPCS + run: vendor/bin/phpcs --standard=phpcs.xml src/ diff --git a/composer.json b/composer.json index 670b1dc..d675293 100644 --- a/composer.json +++ b/composer.json @@ -29,5 +29,7 @@ "ext-json": "*" }, "require-dev": { + "phpstan/phpstan": "^2.2", + "squizlabs/php_codesniffer": "^4.0" } } diff --git a/composer.lock b/composer.lock index 9b77f17..9fe4a3b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,19 +4,168 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a39c07dd1361f33beedacc80c0f0b842", + "content-hash": "53d0a264ca3810805ebbff75073baaa5", "packages": [], - "packages-dev": [], + "packages-dev": [ + { + "name": "phpstan/phpstan", + "version": "2.2.15", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b158556ffd26825cf615a1c1f72fb5157a2301b7", + "reference": "b158556ffd26825cf615a1c1f72fb5157a2301b7", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-09-23T12:23:07+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "bbdc3d0532623e21838b7041a4364383a8126f96" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/bbdc3d0532623e21838b7041a4364383a8126f96", + "reference": "bbdc3d0532623e21838b7041a4364383a8126f96", + "shasum": "" + }, + "require": { + "ext-libxml": "*", + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=7.2.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" + }, + "suggest": { + "ext-iconv": "For accurate character length calculation when the checked files contain multi-byte characters.", + "ext-pcntl": "For parallel processing support via the --parallel CLI option." + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-08-06T02:45:27+00:00" + } + ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">= 7.3", + "php": ">= 8.1", "ext-curl": "*", "ext-json": "*" }, - "platform-dev": [], - "plugin-api-version": "2.6.0" + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..81dbc8f --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,20 @@ + + + TNI Style: opinionated PSR12 + + + + + + + + + + warning + + + + + warning + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..7e93e81 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 9 + paths: + - src/ diff --git a/src/Client.php b/src/Client.php index b54f390..2c345d7 100644 --- a/src/Client.php +++ b/src/Client.php @@ -1,6 +1,5 @@ Date: Wed, 23 Sep 2026 20:47:15 +0200 Subject: [PATCH 3/4] docs: readme update --- README.md | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 29fc100..dcddb96 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ try { $response = $client->post($secret_endpoint, $my_secret_stuff); } catch (\TheNextInvoice\NoREST\Exceptions\RequestFailedException $e) { echo 'oops, request failed: ' . $e->getMessage() . PHP_EOL; + echo 'the response headers are' . $e->getResponseHeaders() . PHP_EOL; echo 'the response body was' . PHP_EOL; echo $e->getBody(); } @@ -32,7 +33,7 @@ Sometimes you need to interface with API's that aren't really playing by the rul a couple of helpers. ### Treat header names case-sensitive -While RFC 2616 section 4.2 says that header field names should be treated in a case-insensitive manner, there are +While RFC 7230 section 3.2.2 says that header field names should be treated in a case-insensitive manner, there are servers that do treat headers case-sensitive. If you want to make sure NoREST does not call `strtolower` on your header key, do the following: ```php @@ -62,3 +63,37 @@ $client = new \TheNextInvoice\NoREST\Client('https//api.example.com', [], [JSON_ ``` All requests made with a client constructed this way will have `JSON_UNESCAPED_SLASHES` and `JSON_HEX_TAG` JSON flags applied when encoding AND decoding the request and response body respectively. + +### Timeouts +Some servers are slower than others, and NoREST's default timeouts of 10s for TLS handshakes and 30s for overall request +time might be too strict. To change those -for instance to 120s total and 10s handshake, simply call: +```php +// Construct client with higher timeouts +$client = new \TheNextInvoice\NoREST\Client('https//api.example.com', [], [], 120, 10); +// Or as a setter, returning a Client +$client = $client->setTimeout(120, 10); +``` + +### Response headers +Sometimes the body alone isn't enough: maybe you need a `Location` header, a `Retry-After`, or you're just trying to +figure out why a response got decoded the way it did. NoREST keeps the headers of the most recently received response +around for you, keyed by lowercased header name: +```php +$client->get('/endpoint'); +$headers = $client->getLastResponseHeaders(); +// e.g. ['content-type' => ['application/json'], 'x-ratelimit-remaining' => ['42']] +``` +Each header maps to a list of values rather than a single string, since some servers do send the same header more +than once (looking at you, `Set-Cookie`). This returns `null` until a request has actually been made on that +instance. + +If the request fails, you don't need a separate call to see what the failing response looked like: the headers are +already on the exception. +```php +try { + $client->get('/endpoint'); +} catch (\TheNextInvoice\NoREST\Exceptions\RequestFailedException $e) { + $headers = $e->getResponseHeaders(); +} +``` +This is empty when the request failed before a response was even received, such as a due to a connection error. From 6aa90d99adfa8fe65a4a9a99539eeff6a0022642 Mon Sep 17 00:00:00 2001 From: joramkruijer Date: Wed, 23 Sep 2026 21:44:48 +0200 Subject: [PATCH 4/4] chore: phpstan fixes --- src/Client.php | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/Client.php b/src/Client.php index 2c345d7..817c0f8 100644 --- a/src/Client.php +++ b/src/Client.php @@ -129,7 +129,9 @@ public function __construct( $headers += [self::CONTENT_TYPE_HEADER => 'application/json']; $this->headers = $headers; - $this->jsonFlags = array_unique($jsonFlags); + // No need to de-duplicate: reduceFlags() only ever bitwise-ORs these together, and + // duplicate flags don't change the result of a bitwise OR. + $this->jsonFlags = $jsonFlags; $this->timeout = $timeout; $this->connectTimeout = $connectTimeout; } @@ -325,7 +327,7 @@ protected function sendRequest(string $url, string $method, mixed $payload = nul // content type is. Code taken from // https://stackoverflow.com/questions/9183178/can-php-curl-retrieve-response-headers-and-body-in-a-single-request#41135574 CURLOPT_HEADERFUNCTION => - static function ($curl, $header) use (&$responseHeaders) { + static function (CurlHandle $curl, string $header) use (&$responseHeaders): int { $len = strlen($header); $header = explode(':', $header, 2); if (count($header) < 2) { @@ -424,13 +426,11 @@ private function encodeBody(mixed $body): string */ private function reduceFlags(array $flags): int { - return (int)array_reduce( - $flags, - static function ($carry, $flag) { - return $carry | $flag; - }, - reset($flags) - ); + $result = 0; + foreach ($flags as $flag) { + $result |= $flag; + } + return $result; } /** @@ -468,10 +468,14 @@ private function collapseHeaders(): void * If $uri is only a path segment, it prepends the BaseUrl * * @param string $uri request URL, either partial or full - * @return string + * @return non-empty-string */ private function combineUrl(string $uri): string { + if ($uri === '') { + throw new InvalidArgumentException('Client: URI must not be empty'); + } + // Check if $uri is a full uri $parsed = parse_url($uri); if ($parsed !== false && isset($parsed['host'])) {