Skip to content

Commit c75a979

Browse files
committed
feat(v3): add response decoder formats
1 parent 3ba7116 commit c75a979

14 files changed

Lines changed: 336 additions & 31 deletions

‎composer.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
],
1414
"require": {
1515
"php": ">=8.1",
16+
"ext-simplexml": "*",
1617
"nyholm/append-query-string": "^1.0",
1718
"php-http/cache-plugin": "^2.1",
1819
"php-http/client-common": "^2.7",

‎docs/api.md‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -172,17 +172,18 @@ Protected access to response decoding configuration.
172172

173173
```php
174174
$this->responses()->json();
175+
$this->responses()->xml();
176+
$this->responses()->custom($decoder);
175177
```
176178

177-
When JSON decoding is enabled:
179+
Available response formats:
178180

179-
- JSON response bodies are decoded into arrays.
180-
- Empty response bodies become `null`.
181-
- Invalid JSON throws `JsonException`.
181+
- `raw()`: response bodies are returned as strings.
182+
- `json()`: response bodies are decoded into arrays; empty bodies become `null`; invalid JSON throws `JsonException`.
183+
- `xml()`: response bodies are decoded into `SimpleXMLElement`; empty bodies become `null`; invalid XML throws `RuntimeException`.
184+
- `custom()`: receives the raw PSR response and returns the value used as `Response::data()`.
182185

183-
When JSON decoding is not enabled, `Response::data()` returns the raw response body string.
184-
185-
This area will grow as response transforms and errors are finalized.
186+
When no format is configured, `raw()` is used.
186187

187188
## `errors(): ErrorBuilder`
188189

‎docs/responses.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ Returns response data.
1212

1313
When `responses()->json()` is enabled on the API, JSON bodies are decoded into arrays, empty bodies become `null`, and invalid JSON throws `JsonException`.
1414

15-
When JSON decoding is not enabled, this returns the raw response body string.
15+
When `responses()->xml()` is enabled, XML bodies are decoded into `SimpleXMLElement`, empty bodies become `null`, and invalid XML throws `RuntimeException`.
16+
17+
When `responses()->custom()` is enabled, the configured callable receives the raw PSR response and returns the value used as `Response::data()`.
18+
19+
When no response format is configured, this returns the raw response body string.
1620

1721
```php
1822
$data = $response->data();

‎docs/v3-architecture-plan.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon
342342
- Do not add a generic collection object in the first phase. A future `collect()` helper can be considered later if arrays become limiting.
343343
- Symfony EventDispatcher has been replaced with a smaller request/response pipeline.
344344
- v3-native hooks are represented by `HookBuilder`, `RequestContext`, and `ResponseContext`.
345+
- Response body decoding is represented by `ResponseDecoder` and `ResponseFormat`; transport returns raw PSR responses and does not decode. Common formats use `raw()`, `json()`, and `xml()`, while custom response decoding uses `custom()`.
345346
- Method constants are not central to v3 because resources expose `get`, `post`, `put`, `patch`, and `delete` helpers.
346347
- Prefer fluent configuration over public getters.
347348
- Use HTTPlug `PluginClientBuilder` behavior for plugin priority ordering and same-priority plugin preservation.

‎src/Api.php‎

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace ProgrammatorDev\Api;
44

5+
use JsonException;
56
use ProgrammatorDev\Api\Builder\AuthBuilder;
67
use ProgrammatorDev\Api\Builder\CacheBuilder;
78
use ProgrammatorDev\Api\Builder\ClientBuilder;
@@ -12,11 +13,12 @@
1213
use ProgrammatorDev\Api\Builder\ResponseBuilder;
1314
use ProgrammatorDev\Api\Context\ErrorContext;
1415
use ProgrammatorDev\Api\Request\RequestOptions;
15-
use Psr\Http\Client\ClientExceptionInterface as ClientException;
16+
use Psr\Http\Client\ClientExceptionInterface;
1617
use Psr\Http\Client\ClientInterface;
1718
use Psr\Cache\CacheItemPoolInterface;
18-
use Psr\Http\Message\ResponseInterface;
1919
use Psr\Log\LoggerInterface;
20+
use RuntimeException;
21+
use Throwable;
2022

2123
class Api
2224
{
@@ -56,7 +58,10 @@ public function __construct()
5658
}
5759

5860
/**
59-
* @throws ClientException
61+
* @throws ClientExceptionInterface
62+
* @throws JsonException
63+
* @throws RuntimeException
64+
* @throws Throwable
6065
*/
6166
public function send(
6267
string $method,
@@ -77,7 +82,7 @@ public function send(
7782
);
7883

7984
$apiResponse = new Response(
80-
data: $this->getResponseData($response),
85+
data: $this->responseDecoder()->decode($response),
8186
rawResponse: $response,
8287
context: $context
8388
);
@@ -188,19 +193,8 @@ private function transport(): Transport
188193
);
189194
}
190195

191-
private function getResponseData(ResponseInterface $response): mixed
196+
private function responseDecoder(): ResponseDecoder
192197
{
193-
$response->getBody()->rewind();
194-
$contents = $response->getBody()->getContents();
195-
196-
if (!$this->responseBuilder->shouldDecodeJson()) {
197-
return $contents;
198-
}
199-
200-
if ($contents === '') {
201-
return null;
202-
}
203-
204-
return json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
198+
return new ResponseDecoder($this->responseBuilder);
205199
}
206200
}

‎src/Builder/ErrorBuilder.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ public function when(callable $handler): self
5151
return $this;
5252
}
5353

54+
/**
55+
* @throws \Throwable
56+
*/
5457
public function throwIfMatched(ErrorContext $context): void
5558
{
5659
$handler = $this->statusHandlers[$context->statusCode()] ?? null;

‎src/Builder/HookBuilder.php‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ public function afterResponse(callable $hook, int $priority = 0): self
3838
return $this;
3939
}
4040

41+
/**
42+
* @throws UnexpectedValueException
43+
*/
4144
public function applyBeforeRequestHooks(RequestContext $context): RequestInterface
4245
{
4346
$request = $context->request();
@@ -59,6 +62,9 @@ public function applyBeforeRequestHooks(RequestContext $context): RequestInterfa
5962
return $request;
6063
}
6164

65+
/**
66+
* @throws UnexpectedValueException
67+
*/
6268
public function applyAfterResponseHooks(ResponseContext $context): ResponseInterface
6369
{
6470
$response = $context->response();

‎src/Builder/ResponseBuilder.php‎

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,61 @@
22

33
namespace ProgrammatorDev\Api\Builder;
44

5+
use ProgrammatorDev\Api\ResponseFormat;
6+
use Psr\Http\Message\ResponseInterface;
7+
58
class ResponseBuilder
69
{
7-
private bool $decodeJson = false;
10+
private ResponseFormat $format = ResponseFormat::Raw;
11+
12+
/** @var null|callable(ResponseInterface): mixed */
13+
private $customDecoder = null;
14+
15+
public function raw(): self
16+
{
17+
$this->format = ResponseFormat::Raw;
18+
$this->customDecoder = null;
19+
20+
return $this;
21+
}
822

923
public function json(): self
1024
{
11-
$this->decodeJson = true;
25+
$this->format = ResponseFormat::Json;
26+
$this->customDecoder = null;
27+
28+
return $this;
29+
}
30+
31+
public function xml(): self
32+
{
33+
$this->format = ResponseFormat::Xml;
34+
$this->customDecoder = null;
35+
36+
return $this;
37+
}
38+
39+
/**
40+
* @param callable(ResponseInterface): mixed $decoder
41+
*/
42+
public function custom(callable $decoder): self
43+
{
44+
$this->format = ResponseFormat::Custom;
45+
$this->customDecoder = $decoder;
1246

1347
return $this;
1448
}
1549

16-
public function shouldDecodeJson(): bool
50+
public function format(): ResponseFormat
51+
{
52+
return $this->format;
53+
}
54+
55+
/**
56+
* @return null|callable(ResponseInterface): mixed
57+
*/
58+
public function customDecoder(): ?callable
1759
{
18-
return $this->decodeJson;
60+
return $this->customDecoder;
1961
}
2062
}

‎src/ResponseDecoder.php‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\Api;
4+
5+
use ProgrammatorDev\Api\Builder\ResponseBuilder;
6+
use Psr\Http\Message\ResponseInterface;
7+
use RuntimeException;
8+
use SimpleXMLElement;
9+
10+
final class ResponseDecoder
11+
{
12+
public function __construct(
13+
private readonly ResponseBuilder $responseBuilder
14+
) {}
15+
16+
/**
17+
* @throws \JsonException
18+
* @throws RuntimeException
19+
*/
20+
public function decode(ResponseInterface $response): mixed
21+
{
22+
$response->getBody()->rewind();
23+
$contents = $response->getBody()->getContents();
24+
25+
return match ($this->responseBuilder->format()) {
26+
ResponseFormat::Raw => $contents,
27+
ResponseFormat::Json => $this->decodeJson($contents),
28+
ResponseFormat::Xml => $this->decodeXml($contents),
29+
ResponseFormat::Custom => $this->decodeCustom($response),
30+
};
31+
}
32+
33+
/**
34+
* @throws \JsonException
35+
*/
36+
private function decodeJson(string $contents): mixed
37+
{
38+
return $contents === ''
39+
? null
40+
: json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
41+
}
42+
43+
private function decodeXml(string $contents): ?SimpleXMLElement
44+
{
45+
if ($contents === '') {
46+
return null;
47+
}
48+
49+
$previous = libxml_use_internal_errors(true);
50+
libxml_clear_errors();
51+
52+
$xml = simplexml_load_string($contents);
53+
$errors = libxml_get_errors();
54+
55+
libxml_clear_errors();
56+
libxml_use_internal_errors($previous);
57+
58+
if (!$xml instanceof SimpleXMLElement) {
59+
$message = $errors[0]->message ?? 'Invalid XML response body.';
60+
61+
throw new RuntimeException(trim($message));
62+
}
63+
64+
return $xml;
65+
}
66+
67+
private function decodeCustom(ResponseInterface $response): mixed
68+
{
69+
$decoder = $this->responseBuilder->customDecoder();
70+
71+
if ($decoder === null) {
72+
throw new RuntimeException('A custom response decoder must be configured.');
73+
}
74+
75+
return $decoder($response);
76+
}
77+
}

‎src/ResponseFormat.php‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\Api;
4+
5+
enum ResponseFormat
6+
{
7+
case Raw;
8+
case Json;
9+
case Xml;
10+
case Custom;
11+
}

0 commit comments

Comments
 (0)