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/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.
diff --git a/composer.json b/composer.json
index 580ee51..d675293 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,10 +24,12 @@
}
},
"require": {
- "php": ">= 7.3",
+ "php": ">= 8.1",
"ext-curl": "*",
"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 8048119..817c0f8 100644
--- a/src/Client.php
+++ b/src/Client.php
@@ -1,7 +1,8 @@
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);
+ // 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;
}
/**
@@ -104,7 +149,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 +162,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 +176,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 +196,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 +207,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 +247,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 +260,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 +277,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 +305,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 += [
@@ -251,7 +327,7 @@ protected function sendRequest(string $url, string $method, $payload = null)
// 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) {
@@ -271,11 +347,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 +371,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}");
}
}
@@ -324,13 +426,21 @@ private function encodeBody($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;
+ }
+
+ /**
+ * 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');
}
/**
@@ -358,13 +468,17 @@ 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 !== null && isset($parsed['host'])) {
+ if ($parsed !== false && isset($parsed['host'])) {
return $uri;
}
@@ -377,10 +491,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 +502,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 +519,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 +546,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 +563,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 +579,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 +595,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 +611,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..d97bfc9 100644
--- a/src/Exceptions/Exception.php
+++ b/src/Exceptions/Exception.php
@@ -1,4 +1,5 @@
+ */
+ 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;
+ }
}