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']; + } +}