diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5e4977..ada64a1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,16 @@
Latest
------
+### Changes
+* [#31](https://github.com/cleverage/rest-process-bundle/issues/31) Add missing tests: RequestTask and Client with a mocked HTTP client, MissingClientException, bundle and DI extension.
+* [#39](https://github.com/cleverage/rest-process-bundle/issues/39) Add `Client::getUri()`, deprecate the misspelled `geUri()`; type `MissingClientException::create()`. Update documentation, add tests.
+
+### Fixes
+* [#35](https://github.com/cleverage/rest-process-bundle/issues/35) Fix RequestTask: a `3xx` / `4xx` / `5xx` status code listed in `valid_response_code` outputs the response body (the task still failed). Update documentation, add tests.
+* [#36](https://github.com/cleverage/rest-process-bundle/issues/36) Fix RequestTask: log transport errors (the log was lost, reading the response details threw again). Update documentation, add tests.
+* [#37](https://github.com/cleverage/rest-process-bundle/issues/37) Fix RequestTask `log_response`: log the requested URL, and the status code, headers and content of the response (instead of the configured URL and the response object). Update documentation, add tests.
+* [#38](https://github.com/cleverage/rest-process-bundle/issues/38) Fix RequestTask: throw an explicit `\UnexpectedValueException` on a non-array input; Client: convert scalar URL parameters to strings (a `TypeError` was triggered). Update documentation, add tests.
+
v3.1
------
diff --git a/docs/reference/client.md b/docs/reference/client.md
index 20186a2..4a23290 100644
--- a/docs/reference/client.md
+++ b/docs/reference/client.md
@@ -46,8 +46,9 @@ Any other key throws a `Symfony\Component\OptionsResolver\Exception\UndefinedOpt
The request is then built this way:
- **URL**: `/`, the leading `/` of `url` being removed. Then each `{key}` of `url_parameters` is
- replaced by its value, encoded with [`rawurlencode()`](https://www.php.net/manual/en/function.rawurlencode.php)
- (values must be strings).
+ replaced by its value, converted to a string and encoded with
+ [`rawurlencode()`](https://www.php.net/manual/en/function.rawurlencode.php) (a non-scalar value throws an
+ `\UnexpectedValueException`).
- **Method**: must be one of `HEAD`, `GET`, `POST`, `PUT`, `DELETE`, `OPTIONS`, `TRACE`, `PATCH` (case-sensitive),
otherwise a `CleverAge\RestProcessBundle\Exception\RestRequestException` (` is not an HTTP method`) is thrown.
- **Headers**: `headers`, plus `Content-Type: ` and `Accept: ` when these options are not empty
@@ -81,14 +82,14 @@ A client must implement `ClientInterface`:
| Method | Description |
|-------------------------------------------------------|----------------------------------------------------------------------------------------------|
| `getCode(): string` | Code of the client, used by the `client` option of the task. Must be unique |
-| `geUri(): string` | Base URI of the API (the method name is misspelled, `geUri`, in the interface) |
+| `geUri(): string` | **Deprecated** (misspelled): base URI, use `getUri()` of the default `Client` |
| `setUri(string $uri): void` | Change the base URI |
| `call(array $options = []): ResponseInterface` | Send the request described by the request options, return a Symfony HttpClient response |
The simplest way is to extend the default `Client` and override one of its protected methods:
- `configureOptions(OptionsResolver $resolver)`: request options accepted by `call()`
- `getRequestOptions(array $options)`: Symfony HttpClient options (headers, `json`, `query`, `body`...)
-- `getApiUrl()`: base URI used to build the request URL (defaults to `geUri()`)
+- `getApiUrl()`: base URI used to build the request URL (defaults to `getUri()`)
- `constructUri(array $options)` / `replaceParametersInUri(string $uri, array $options)`: URL construction
Examples
diff --git a/docs/reference/tasks/request_task.md b/docs/reference/tasks/request_task.md
index d3a1644..96eb4d4 100644
--- a/docs/reference/tasks/request_task.md
+++ b/docs/reference/tasks/request_task.md
@@ -16,13 +16,14 @@ Accepted inputs
`array` or empty value (`null`, `[]`...): request options overriding the task options (shallow merge, the input wins).
Allowed keys are `url`, `method`, `headers`, `url_parameters`, `sends`, `expects` and `data`: any other key is passed to
-the client, which rejects it (the default client throws an `UndefinedOptionsException`).
+the client, which rejects it (the default client throws an `UndefinedOptionsException`). Any other non-empty input
+(e.g. a `string`) throws an `\UnexpectedValueException`.
Possible outputs
----------------
`string`: the body of the response (empty string for a `204 No Content`), as returned by
-`Symfony\Contracts\HttpClient\ResponseInterface::getContent()`. It is not decoded: chain a
+`Symfony\Contracts\HttpClient\ResponseInterface::getContent(false)`. It is not decoded: chain a
[DeserializerTask](https://github.com/cleverage/process-bundle/blob/main/docs/reference/tasks/deserializer_task.md) or a
[TransformerTask](https://github.com/cleverage/process-bundle/blob/main/docs/reference/tasks/transformer_task.md) to
decode it.
@@ -39,12 +40,12 @@ Options
| `url` | `string` | **X** | | Path of the endpoint, appended to the client base URI (a leading `/` is optional). May contain `{placeholders}` replaced by `url_parameters` |
| `method` | `string` | **X** | | HTTP method, in uppercase, among `HEAD`, `GET`, `POST`, `PUT`, `DELETE`, `OPTIONS`, `TRACE`, `PATCH` (checked by the default client) |
| `headers` | `array` | | `[]` | HTTP headers, as `name => value` |
-| `url_parameters` | `array` | | `[]` | List of `placeholder => value`: each `{placeholder}` of the URL is replaced by the URL-encoded value (values must be strings) |
+| `url_parameters` | `array` | | `[]` | List of `placeholder => value`: each `{placeholder}` of the URL is replaced by the URL-encoded value (scalar values, converted to strings) |
| `data` | `array`, `string`, `null` | | `null` | Payload of the request, sent as JSON body, query string or raw body depending on `method` and `sends` (see [REST client](../client.md#request-options)) |
| `sends` | `string` | | `application/json` | Value of the `Content-Type` header (not sent if empty) |
| `expects` | `string` | | `application/json` | Value of the `Accept` header (not sent if empty) |
| `valid_response_code` | `array` | | `[200, 201, 204]` | List of the [HTTP status codes](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes) considered as a success |
-| `log_response` | `bool` | | `false` | Log the request options and the response object (`debug` level) once the response is received |
+| `log_response` | `bool` | | `false` | Log the requested URL, the request options, and the status code, headers and content of the response (`debug` level) |
Options are resolved once per process execution: [contextual values](https://github.com/cleverage/process-bundle/blob/main/docs/01-quick_start.md#contextual-values)
like `'{{ code }}'` (passed with `-c code:"'value'"`) are allowed in any option. Use the input to change the request for
@@ -164,9 +165,10 @@ Notes
* When the status code is not in `valid_response_code`, the task sets the raw response body as error output, then
fails with an `Invalid response code` exception, logged with the response headers and body. The process then follows
the task `error_strategy`: `skip` goes on with the next item, `stop` stops the process.
-* The response content is read with `getContent()`, which throws for `3xx`, `4xx` and `5xx` status codes: adding
- such a code to `valid_response_code` only prevents the error log, the task still fails. Redirections are followed by
- the HTTP client, so a `3xx` status is only received when redirections are disabled or exceeded.
-* A transport error (DNS failure, timeout...) or an unknown `method` makes the task fail as well.
+* A `3xx`, `4xx` or `5xx` status code listed in `valid_response_code` is handled as a success: the response body is
+ output. Redirections are followed by the HTTP client, so a `3xx` status is only received when redirections are
+ disabled or exceeded.
+* A transport error (DNS failure, timeout...) makes the task fail as well: it is logged (`REST request failed`, without
+ the response headers and body) then thrown. An unknown `method` makes the task fail too.
* A `client` that is not registered throws a `CleverAge\RestProcessBundle\Exception\MissingClientException`
(`No rest client with code : `) on the first execution of the task.
diff --git a/src/Client/Client.php b/src/Client/Client.php
index a9f5827..d940d9f 100644
--- a/src/Client/Client.php
+++ b/src/Client/Client.php
@@ -42,11 +42,19 @@ public function getCode(): string
return $this->code;
}
- public function geUri(): string
+ public function getUri(): string
{
return $this->uri;
}
+ /**
+ * @deprecated typo, use getUri() instead
+ */
+ public function geUri(): string
+ {
+ return $this->getUri();
+ }
+
public function setUri(string $uri): void
{
$this->uri = $uri;
@@ -182,7 +190,7 @@ protected function constructUri(array $options): string
protected function getApiUrl(): string
{
- return $this->geUri();
+ return $this->getUri();
}
/**
@@ -199,13 +207,15 @@ static function (&$item, $key) {
$item = '{'.$item.'}';
}
);
- /** @var array $replace */
- $replace = array_values($options['url_parameters']);
- array_walk(
- $replace,
- static function (&$item, $key) {
- $item = rawurlencode($item);
- }
+ $replace = array_map(
+ static function (mixed $value): string {
+ if (!\is_scalar($value)) {
+ throw new \UnexpectedValueException(\sprintf('URL parameters must be scalar values, %s given', get_debug_type($value)));
+ }
+
+ return rawurlencode((string) $value);
+ },
+ array_values($options['url_parameters'])
);
$uri = str_replace($search, $replace, $uri);
diff --git a/src/Client/ClientInterface.php b/src/Client/ClientInterface.php
index b08bf2a..aa9902a 100644
--- a/src/Client/ClientInterface.php
+++ b/src/Client/ClientInterface.php
@@ -25,6 +25,9 @@ interface ClientInterface
*/
public function getCode(): string;
+ /**
+ * @deprecated typo, implement and use getUri() instead (it will replace this method in the next major version)
+ */
public function geUri(): string;
public function setUri(string $uri): void;
diff --git a/src/Exception/MissingClientException.php b/src/Exception/MissingClientException.php
index f6836e1..9cbe054 100644
--- a/src/Exception/MissingClientException.php
+++ b/src/Exception/MissingClientException.php
@@ -20,12 +20,7 @@
*/
class MissingClientException extends RestException
{
- /**
- * @param string $code
- *
- * @return MissingClientException
- */
- public static function create($code)
+ public static function create(string $code): self
{
$errorStr = "No rest client with code : {$code}";
diff --git a/src/Task/RequestTask.php b/src/Task/RequestTask.php
index bf41dcf..e6227c0 100644
--- a/src/Task/RequestTask.php
+++ b/src/Task/RequestTask.php
@@ -26,6 +26,7 @@
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @phpstan-type Options array{
@@ -76,19 +77,22 @@ public function execute(ProcessState $state): void
['requestOptions' => $requestOptions]
);
$response = $this->registry->getClient($options['client'])->call($requestOptions);
- if ($options['log_response']) {
- $this->logger->debug(
- "Response received from '{$options['url']}'",
- [
- 'requestOptions' => $requestOptions,
- 'result' => $response,
- ]
- );
- }
- // Handle empty results
try {
- if (!\in_array($response->getStatusCode(), $options['valid_response_code'], false)) {
+ $statusCode = $response->getStatusCode();
+ if ($options['log_response']) {
+ $this->logger->debug(
+ "Response received from '{$requestOptions['url']}'",
+ [
+ 'requestOptions' => $requestOptions,
+ 'status_code' => $statusCode,
+ 'headers' => $response->getHeaders(false),
+ 'content' => $response->getContent(false),
+ ]
+ );
+ }
+
+ if (!\in_array($statusCode, $options['valid_response_code'], false)) {
$state->setErrorOutput($response->getContent(false));
if (TaskConfiguration::STRATEGY_SKIP === $state->getTaskConfiguration()->getErrorStrategy()) {
@@ -100,38 +104,41 @@ public function execute(ProcessState $state): void
throw new \Exception('Invalid response code');
}
- $state->setOutput($response->getContent());
+ // The status code is valid: do not throw for 3xx / 4xx / 5xx codes listed in valid_response_code
+ $state->setOutput($response->getContent(false));
} catch (\Throwable $e) {
- $allowRedirectionException = false;
- $allowClientException = false;
- foreach ($options['valid_response_code'] as $code) {
- if ($code >= 300 && $code < 400) {
- $allowRedirectionException = true;
- }
- if ($code >= 400 && $code < 500) {
- $allowClientException = true;
- }
- }
- if ((!$allowRedirectionException || !$e instanceof RedirectionExceptionInterface)
- && (!$allowClientException || !$e instanceof ClientExceptionInterface)
- ) {
- $this->logger->error(
- 'REST request failed',
- [
- 'client' => $options['client'],
- 'options' => $options,
- 'request_options' => $requestOptions,
- 'message' => $e->getMessage(),
- 'raw_headers' => $response->getHeaders(false),
- 'raw_body' => $response->getContent(false),
- ]
- );
- }
+ $this->logger->error(
+ 'REST request failed',
+ [
+ 'client' => $options['client'],
+ 'options' => $options,
+ 'request_options' => $requestOptions,
+ 'message' => $e->getMessage(),
+ ...$this->getResponseDetails($response),
+ ]
+ );
throw $e;
}
}
+ /**
+ * Headers and body of the response, for the error log: not available after a transport error.
+ *
+ * @return array{raw_headers?: array>, raw_body?: string}
+ */
+ protected function getResponseDetails(ResponseInterface $response): array
+ {
+ try {
+ return [
+ 'raw_headers' => $response->getHeaders(false),
+ 'raw_body' => $response->getContent(false),
+ ];
+ } catch (TransportExceptionInterface) {
+ return [];
+ }
+ }
+
/**
* @throws UndefinedOptionsException
* @throws AccessException
@@ -182,8 +189,10 @@ protected function getRequestOptions(ProcessState $state): array
'data' => $options['data'],
];
- /** @var array $input */
$input = $state->getInput() ?: [];
+ if (!\is_array($input)) {
+ throw new \UnexpectedValueException(\sprintf('RequestTask expects an array or empty input, %s given', get_debug_type($input)));
+ }
/** @var RequestOptions $mergedOptions */
$mergedOptions = array_merge($requestOptions, $input);
diff --git a/tests/CleverAgeRestProcessBundleTest.php b/tests/CleverAgeRestProcessBundleTest.php
new file mode 100644
index 0000000..8abe1a3
--- /dev/null
+++ b/tests/CleverAgeRestProcessBundleTest.php
@@ -0,0 +1,58 @@
+getPath();
+
+ self::assertSame(\dirname(__DIR__), $path);
+ self::assertDirectoryExists($path.'/config/services');
+ }
+
+ public function testTaggedClientsAreRegistered(): void
+ {
+ $container = new ContainerBuilder();
+ (new CleverAgeRestProcessBundle())->build($container);
+ $container->setDefinition('cleverage_rest_process.registry.client', new Definition(ClientRegistry::class))
+ ->setPublic(true);
+ $container->setDefinition('app.client', new Definition(Client::class, [
+ new Definition(MockHttpClient::class),
+ new Definition(NullLogger::class),
+ 'api',
+ 'https://example.com/api',
+ ]))->addTag('cleverage.rest.client');
+ $container->compile(true);
+
+ /** @var ClientRegistry $registry */
+ $registry = $container->get('cleverage_rest_process.registry.client');
+ self::assertSame('api', $registry->getClient('api')->getCode());
+ }
+}
diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php
new file mode 100644
index 0000000..1e640d1
--- /dev/null
+++ b/tests/Client/ClientTest.php
@@ -0,0 +1,167 @@
+}> */
+ private array $requests = [];
+
+ public function testAccessors(): void
+ {
+ $client = $this->createClient();
+
+ self::assertSame('api', $client->getCode());
+ self::assertSame('https://example.com/api', $client->getUri());
+ self::assertSame('https://example.com/api', $client->geUri());
+ self::assertInstanceOf(NullLogger::class, $client->getLogger());
+
+ $client->setUri('https://other.example.com');
+ self::assertSame('https://other.example.com', $client->getUri());
+ }
+
+ public function testGetWithQueryAndUrlParameters(): void
+ {
+ $response = $this->call($this->createClient(), [
+ 'url' => '/communes/{code}/{name}',
+ 'url_parameters' => ['code' => 46800, 'name' => 'a b/c'],
+ 'data' => ['page' => 2],
+ ]);
+
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('GET', $this->requests[0]['method']);
+ // Scalar url parameters are converted to strings and encoded, data is sent as query string
+ self::assertSame('https://example.com/api/communes/46800/a%20b%2Fc?page=2', $this->requests[0]['url']);
+ self::assertContains('Accept: application/json', $this->getHeaders());
+ self::assertContains('Content-Type: application/json', $this->getHeaders());
+ }
+
+ public function testPostJson(): void
+ {
+ $this->call($this->createClient(), ['url' => 'books', 'method' => 'POST', 'data' => ['title' => 'It']]);
+
+ self::assertSame('POST', $this->requests[0]['method']);
+ self::assertSame('https://example.com/api/books', $this->requests[0]['url']);
+ self::assertSame('{"title":"It"}', $this->requests[0]['options']['body']);
+ }
+
+ public function testRawBody(): void
+ {
+ $this->call($this->createClient(), [
+ 'url' => 'books',
+ 'method' => 'POST',
+ 'sends' => 'text/plain',
+ 'expects' => '',
+ 'headers' => ['X-Token' => 'secret'],
+ 'data' => 'raw content',
+ ]);
+
+ self::assertSame('raw content', $this->requests[0]['options']['body']);
+ self::assertContains('X-Token: secret', $this->getHeaders());
+ self::assertContains('Content-Type: text/plain', $this->getHeaders());
+ self::assertNotContains('Accept: application/json', $this->getHeaders());
+ }
+
+ public function testInvalidMethod(): void
+ {
+ $this->expectException(RestRequestException::class);
+ $this->expectExceptionMessage('FETCH is not an HTTP method');
+ $this->call($this->createClient(), ['url' => 'books', 'method' => 'FETCH']);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideInvalidUrlParameters(): iterable
+ {
+ yield 'array' => [['46800']];
+ yield 'null' => [null];
+ }
+
+ #[DataProvider('provideInvalidUrlParameters')]
+ public function testInvalidUrlParameter(mixed $value): void
+ {
+ $this->expectException(\UnexpectedValueException::class);
+ $this->expectExceptionMessage('URL parameters must be scalar values');
+ $this->call($this->createClient(), ['url' => '/communes/{code}', 'url_parameters' => ['code' => $value]]);
+ }
+
+ public function testUnknownOption(): void
+ {
+ $this->expectException(UndefinedOptionsException::class);
+ $this->call($this->createClient(), ['url' => 'books', 'client' => 'api']);
+ }
+
+ public function testRequestFailure(): void
+ {
+ $httpClient = new MockHttpClient(static function (): never {
+ throw new \RuntimeException('Invalid request');
+ });
+ $client = new Client($httpClient, new NullLogger(), 'api', 'https://example.com/api');
+
+ $this->expectException(RestRequestException::class);
+ $this->expectExceptionMessage('Rest request failed');
+ $this->call($client, ['url' => 'books']);
+ }
+
+ /**
+ * @param array $options Partial options, completed with the defaults by the client
+ */
+ private function call(Client $client, array $options): ResponseInterface
+ {
+ // @phpstan-ignore argument.type
+ return $client->call($options);
+ }
+
+ private function createClient(): Client
+ {
+ $httpClient = new MockHttpClient(
+ /**
+ * @param array $options
+ */
+ function (string $method, string $url, array $options): MockResponse {
+ $this->requests[] = ['method' => $method, 'url' => $url, 'options' => $options];
+
+ return new MockResponse('[]');
+ }
+ );
+
+ return new Client($httpClient, new NullLogger(), 'api', 'https://example.com/api');
+ }
+
+ /**
+ * @return list
+ */
+ private function getHeaders(): array
+ {
+ /** @var list $headers */
+ $headers = $this->requests[0]['options']['headers'] ?? [];
+
+ return $headers;
+ }
+}
diff --git a/tests/DependencyInjection/CleverAgeRestProcessExtensionTest.php b/tests/DependencyInjection/CleverAgeRestProcessExtensionTest.php
new file mode 100644
index 0000000..6b11dea
--- /dev/null
+++ b/tests/DependencyInjection/CleverAgeRestProcessExtensionTest.php
@@ -0,0 +1,45 @@
+load([], $container);
+
+ self::assertSame(ClientRegistry::class, $container->getDefinition('cleverage_rest_process.registry.client')->getClass());
+
+ $definition = $container->getDefinition('cleverage_rest_process.task.request');
+ self::assertSame(RequestTask::class, $definition->getClass());
+ // Tasks are stateful: each process execution must get its own instance
+ self::assertFalse($definition->isShared());
+ self::assertEquals([new Reference('logger'), new Reference('cleverage_rest_process.registry.client')], $definition->getArguments());
+
+ // Referenced as '@' in process configurations
+ $alias = $container->getAlias(RequestTask::class);
+ self::assertSame('cleverage_rest_process.task.request', (string) $alias);
+ self::assertTrue($alias->isPublic());
+ }
+}
diff --git a/tests/Exception/MissingClientExceptionTest.php b/tests/Exception/MissingClientExceptionTest.php
new file mode 100644
index 0000000..efb83c2
--- /dev/null
+++ b/tests/Exception/MissingClientExceptionTest.php
@@ -0,0 +1,31 @@
+getMessage());
+ }
+}
diff --git a/tests/Task/RequestTaskTest.php b/tests/Task/RequestTaskTest.php
new file mode 100644
index 0000000..74f6ad5
--- /dev/null
+++ b/tests/Task/RequestTaskTest.php
@@ -0,0 +1,246 @@
+}> */
+ private array $logs = [];
+
+ /** @var list */
+ private array $urls = [];
+
+ public function testOutputsTheResponseBody(): void
+ {
+ [$task, $state] = $this->createTask([], new MockResponse('{"id":1}'));
+
+ $task->execute($state);
+
+ self::assertSame('{"id":1}', $state->getOutput());
+ self::assertSame(['https://example.com/api/books/1'], $this->urls);
+ self::assertSame(['debug'], array_column($this->logs, 'level'));
+ }
+
+ public function testInputOverridesTheOptions(): void
+ {
+ [$task, $state] = $this->createTask([], new MockResponse('{}'));
+ $state->setInput(['url' => '/books/{id}', 'url_parameters' => ['id' => 2]]);
+
+ $task->execute($state);
+
+ self::assertSame(['https://example.com/api/books/2'], $this->urls);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideValidErrorCodes(): iterable
+ {
+ yield 'redirection' => [304];
+ yield 'client error' => [404];
+ yield 'server error' => [503];
+ }
+
+ #[DataProvider('provideValidErrorCodes')]
+ public function testErrorCodeDeclaredValid(int $code): void
+ {
+ [$task, $state] = $this->createTask(
+ ['valid_response_code' => [200, $code]],
+ new MockResponse('{"error":true}', ['http_code' => $code])
+ );
+
+ $task->execute($state);
+
+ self::assertSame('{"error":true}', $state->getOutput());
+ self::assertSame([], array_values(array_filter($this->logs, static fn (array $log): bool => 'error' === $log['level'])));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideErrorStrategies(): iterable
+ {
+ yield 'skip' => [TaskConfiguration::STRATEGY_SKIP, true, false];
+ yield 'stop' => [TaskConfiguration::STRATEGY_STOP, false, true];
+ }
+
+ #[DataProvider('provideErrorStrategies')]
+ public function testInvalidResponseCode(string $errorStrategy, bool $skipped, bool $stopped): void
+ {
+ [$task, $state] = $this->createTask([], new MockResponse('{"error":"not found"}', ['http_code' => 404]), $errorStrategy);
+
+ try {
+ $task->execute($state);
+ self::fail('The task must fail');
+ } catch (\Exception $e) {
+ self::assertSame('Invalid response code', $e->getMessage());
+ }
+
+ self::assertSame('{"error":"not found"}', $state->getErrorOutput());
+ self::assertSame($skipped, $state->isSkipped());
+ self::assertSame($stopped, $state->isStopped());
+ $error = $this->getErrorLog();
+ self::assertSame('Invalid response code', $error['message']);
+ self::assertSame('{"error":"not found"}', $error['raw_body']);
+ self::assertArrayHasKey('raw_headers', $error);
+ }
+
+ public function testTransportErrorIsLogged(): void
+ {
+ [$task, $state] = $this->createTask([], new MockResponse('', ['error' => 'Could not resolve host']));
+
+ try {
+ $task->execute($state);
+ self::fail('The task must fail');
+ } catch (TransportException $e) {
+ self::assertStringContainsString('Could not resolve host', $e->getMessage());
+ }
+
+ $error = $this->getErrorLog();
+ self::assertIsString($error['message']);
+ self::assertStringContainsString('Could not resolve host', $error['message']);
+ // Not available after a transport error
+ self::assertArrayNotHasKey('raw_headers', $error);
+ self::assertArrayNotHasKey('raw_body', $error);
+ }
+
+ public function testErrorWhileReadingTheContentIsLogged(): void
+ {
+ [$task, $state] = $this->createTask([], new MockResponse([new \RuntimeException('Connection reset')]));
+
+ $this->expectException(TransportException::class);
+ try {
+ $task->execute($state);
+ } finally {
+ self::assertSame('Connection reset', $this->getErrorLog()['message']);
+ }
+ }
+
+ public function testLogResponse(): void
+ {
+ [$task, $state] = $this->createTask(['log_response' => true], new MockResponse('{"id":2}', ['response_headers' => ['X-Id: 2']]));
+ $state->setInput(['url' => '/books/2']);
+
+ $task->execute($state);
+
+ $log = $this->logs[1];
+ self::assertSame("Response received from '/books/2'", $log['message']);
+ self::assertSame(200, $log['context']['status_code']);
+ self::assertSame('{"id":2}', $log['context']['content']);
+ self::assertSame(['x-id' => ['2']], $log['context']['headers']);
+ }
+
+ public function testNonArrayInputIsRejected(): void
+ {
+ [$task, $state] = $this->createTask([], new MockResponse('{}'));
+ $state->setInput('/books/2');
+
+ $this->expectException(\UnexpectedValueException::class);
+ $this->expectExceptionMessage('RequestTask expects an array or empty input, string given');
+ $task->execute($state);
+ }
+
+ public function testMissingClient(): void
+ {
+ [$task, $state] = $this->createTask(['client' => 'missing'], new MockResponse('{}'));
+
+ $this->expectException(MissingClientException::class);
+ $this->expectExceptionMessage('No rest client with code : missing');
+ $task->execute($state);
+ }
+
+ /**
+ * @param array $options
+ *
+ * @return array{RequestTask, ProcessState}
+ */
+ private function createTask(array $options, MockResponse $response, string $errorStrategy = TaskConfiguration::STRATEGY_STOP): array
+ {
+ $httpClient = new MockHttpClient(function (string $method, string $url) use ($response): MockResponse {
+ $this->urls[] = $url;
+
+ return $response;
+ });
+ $onLog = function (string $level, string $message, array $context): void {
+ $this->logs[] = ['level' => $level, 'message' => $message, 'context' => $context];
+ };
+ $logger = new class($onLog) extends AbstractLogger {
+ public function __construct(private readonly \Closure $onLog)
+ {
+ }
+
+ /**
+ * @param array $context
+ */
+ public function log($level, string|\Stringable $message, array $context = []): void
+ {
+ ($this->onLog)(\is_string($level) ? $level : '', (string) $message, $context);
+ }
+ };
+
+ $registry = new ClientRegistry();
+ $registry->addClient(new Client($httpClient, $logger, 'api', 'https://example.com/api'));
+
+ $processConfiguration = new ProcessConfiguration('test', []);
+ $state = new ProcessState($processConfiguration, new ProcessHistory($processConfiguration));
+ $state->setContextualOptionResolver(new ContextualOptionResolver());
+ $state->setContext([]);
+ $state->reset(true);
+ $state->setTaskConfiguration(new TaskConfiguration('request', RequestTask::class, $options + [
+ 'client' => 'api',
+ 'url' => '/books/{id}',
+ 'method' => 'GET',
+ 'url_parameters' => ['id' => 1],
+ ], errorStrategy: $errorStrategy));
+
+ $task = new RequestTask($logger, $registry);
+ $task->initialize($state);
+
+ return [$task, $state];
+ }
+
+ /**
+ * @return array
+ */
+ private function getErrorLog(): array
+ {
+ $errors = array_values(array_filter($this->logs, static fn (array $log): bool => 'error' === $log['level']));
+ self::assertCount(1, $errors);
+ self::assertSame('REST request failed', $errors[0]['message']);
+
+ return $errors[0]['context'];
+ }
+}