diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index b8bdcef..deba1e5 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -22,18 +22,4 @@ jobs: - name: Run tests with coverage run: | mkdir -p build - vendor/bin/phpunit \ - --coverage-text \ - --coverage-clover build/coverage.xml - - - name: Upload coverage artifact - uses: actions/upload-artifact@v4 - with: - name: coverage-clover - path: build/coverage.xml - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - files: build/coverage.xml - fail_ci_if_error: true \ No newline at end of file + vendor/bin/phpunit \ No newline at end of file diff --git a/README.md b/README.md index 632a2c7..5c0c00b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ HTTP client for **Yandex Cloud Vision OCR** (sync + async). Designed as a small, predictable, PSR-friendly Composer library with clear errors, DTOs, and optional concurrency via a runner interface.

-Tests status +Tests status Latest Stable Version License

diff --git a/src/Ocr/Command/GetOperationCommand.php b/src/Ocr/Command/GetOperationCommand.php deleted file mode 100644 index 4924287..0000000 --- a/src/Ocr/Command/GetOperationCommand.php +++ /dev/null @@ -1,37 +0,0 @@ -httpClient->sendJsonRequest( - 'GET', - OcrEndpoints::OPERATION_BASE_URI . '/' . rawurlencode($operationId), - $this->requestBuilder->buildHeaders([], false), - null - ); - - return new OperationStatus($operationId, (bool) ($data['done'] ?? false), $data, $meta); - } -} diff --git a/src/Ocr/Command/GetRecognitionCommand.php b/src/Ocr/Command/GetRecognitionCommand.php deleted file mode 100644 index 3da71e8..0000000 --- a/src/Ocr/Command/GetRecognitionCommand.php +++ /dev/null @@ -1,39 +0,0 @@ - $operationId]); - - [$data, $meta] = $this->httpClient->sendJsonRequest( - 'GET', - OcrEndpoints::OCR_BASE_URI . '/getRecognition?' . $query, - $this->requestBuilder->buildHeaders([], false), - null - ); - - return new OcrResponse($data, $meta); - } -} diff --git a/src/Ocr/Command/RecognizeTextCommand.php b/src/Ocr/Command/RecognizeTextCommand.php deleted file mode 100644 index d3db030..0000000 --- a/src/Ocr/Command/RecognizeTextCommand.php +++ /dev/null @@ -1,36 +0,0 @@ - $options - */ - public function execute(string $bytes, string $mime, array $options = []): OcrResponse - { - $payload = $this->requestBuilder->buildRecognizePayload($bytes, $mime, $options); - - [$data, $meta] = $this->httpClient->sendJsonRequest( - 'POST', - OcrEndpoints::OCR_BASE_URI . '/recognizeText', - $this->requestBuilder->buildHeaders($options, true), - $payload - ); - - return new OcrResponse($data, $meta); - } -} diff --git a/src/Ocr/Command/StartTextRecognitionCommand.php b/src/Ocr/Command/StartTextRecognitionCommand.php deleted file mode 100644 index 4bef1bc..0000000 --- a/src/Ocr/Command/StartTextRecognitionCommand.php +++ /dev/null @@ -1,42 +0,0 @@ - $options - */ - public function execute(string $bytes, string $mime, array $options = []): OperationHandle - { - $payload = $this->requestBuilder->buildRecognizePayload($bytes, $mime, $options); - - [$data, $meta] = $this->httpClient->sendJsonRequest( - 'POST', - OcrEndpoints::OCR_BASE_URI . '/recognizeTextAsync', - $this->requestBuilder->buildHeaders($options, true), - $payload - ); - - $operationId = $data['id'] ?? null; - if (!is_string($operationId) || $operationId === '') { - throw new ValidationException('Missing operation id in async recognition response.'); - } - - return new OperationHandle($operationId, $meta['request_id'] ?? null); - } -} diff --git a/src/Ocr/Command/WaitCommand.php b/src/Ocr/Command/WaitCommand.php deleted file mode 100644 index 1ad1d14..0000000 --- a/src/Ocr/Command/WaitCommand.php +++ /dev/null @@ -1,81 +0,0 @@ -getOperationCommand->execute($operationId); - $payload = $status->getPayload(); - - if ($status->isDone()) { - $errorMessage = $this->extractOperationError($payload); - if ($errorMessage !== null) { - throw new ApiException($errorMessage); - } - - $response = $payload['response'] ?? null; - if (is_array($response)) { - return new OcrResponse($response, $status->getMeta()); - } - - return $this->getRecognitionCommand->execute($operationId); - } - - $errorMessage = $this->extractOperationError($payload); - if ($errorMessage !== null) { - throw new ApiException($errorMessage); - } - - $now = time(); - if ($now >= $deadline) { - throw new TimeoutException('OCR operation timed out.'); - } - - $delay = $backoff->getDelayForAttempt($attempt); - $remaining = $deadline - $now; - $sleepSeconds = min($delay, $remaining); - if ($sleepSeconds > 0) { - sleep($sleepSeconds); - } - $attempt++; - } - } - - /** - * @param array $payload - */ - private function extractOperationError(array $payload): ?string - { - $error = $payload['error'] ?? null; - if (!is_array($error)) { - return null; - } - - $message = $error['message'] ?? null; - if (is_string($message) && $message !== '') { - return $message; - } - - return 'OCR operation failed.'; - } -} diff --git a/src/Dto/OcrResponse.php b/src/Ocr/Dto/OcrResponse.php similarity index 92% rename from src/Dto/OcrResponse.php rename to src/Ocr/Dto/OcrResponse.php index 9dde123..0d519ac 100644 --- a/src/Dto/OcrResponse.php +++ b/src/Ocr/Dto/OcrResponse.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpVision\YandexVision\Dto; +namespace PhpVision\YandexVision\Ocr\Dto; final readonly class OcrResponse { diff --git a/src/Dto/OperationStatus.php b/src/Ocr/Dto/OperationStatus.php similarity index 94% rename from src/Dto/OperationStatus.php rename to src/Ocr/Dto/OperationStatus.php index 9a7e195..703b249 100644 --- a/src/Dto/OperationStatus.php +++ b/src/Ocr/Dto/OperationStatus.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpVision\YandexVision\Dto; +namespace PhpVision\YandexVision\Ocr\Dto; final readonly class OperationStatus { diff --git a/src/Ocr/OcrHttpClient.php b/src/Ocr/OcrHttpClient.php index 247c387..77332f2 100644 --- a/src/Ocr/OcrHttpClient.php +++ b/src/Ocr/OcrHttpClient.php @@ -4,6 +4,7 @@ namespace PhpVision\YandexVision\Ocr; +use PhpVision\YandexVision\Ocr\Request\OcrRequestInterface; use PhpVision\YandexVision\Exception\ApiException; use PhpVision\YandexVision\Exception\HttpException; use PhpVision\YandexVision\Exception\ValidationException; @@ -22,12 +23,25 @@ public function __construct( ) { } + /** + * @return array{0: array, 1: array} + */ + public function send(OcrRequestInterface $request): array + { + return $this->sendJsonRequest( + $request->getMethod(), + $request->getUrl(), + $request->getHeaders(), + $request->getBody() + ); + } + /** * @param array $headers * @param array|null $body * @return array{0: array, 1: array} */ - public function sendJsonRequest(string $method, string $url, array $headers, ?array $body): array + private function sendJsonRequest(string $method, string $url, array $headers, ?array $body): array { $request = $this->requestFactory->createRequest($method, $url); foreach ($headers as $name => $value) { diff --git a/src/Ocr/OcrService.php b/src/Ocr/OcrService.php index 3125100..47370c9 100644 --- a/src/Ocr/OcrService.php +++ b/src/Ocr/OcrService.php @@ -7,24 +7,22 @@ use PhpVision\YandexVision\Auth\CredentialProviderInterface; use PhpVision\YandexVision\Concurrency\RunnerInterface; use PhpVision\YandexVision\Concurrency\SequentialRunner; -use PhpVision\YandexVision\Dto\OcrResponse; -use PhpVision\YandexVision\Dto\OperationStatus; -use PhpVision\YandexVision\Ocr\Command\GetOperationCommand; -use PhpVision\YandexVision\Ocr\Command\GetRecognitionCommand; -use PhpVision\YandexVision\Ocr\Command\RecognizeTextCommand; -use PhpVision\YandexVision\Ocr\Command\StartTextRecognitionCommand; -use PhpVision\YandexVision\Ocr\Command\WaitCommand; +use PhpVision\YandexVision\Ocr\Dto\OcrResponse; +use PhpVision\YandexVision\Ocr\Dto\OperationStatus; +use PhpVision\YandexVision\Exception\ApiException; +use PhpVision\YandexVision\Exception\TimeoutException; +use PhpVision\YandexVision\Exception\ValidationException; +use PhpVision\YandexVision\Ocr\Request\OperationRequest; +use PhpVision\YandexVision\Ocr\Request\RecognitionRequest; +use PhpVision\YandexVision\Ocr\Request\TextRecognitionAsyncRequest; +use PhpVision\YandexVision\Ocr\Request\TextRecognitionRequest; use PhpVision\YandexVision\Transports\TransportInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; final readonly class OcrService { - private RecognizeTextCommand $recognizeTextCommand; - private StartTextRecognitionCommand $startTextRecognitionCommand; - private GetOperationCommand $getOperationCommand; - private GetRecognitionCommand $getRecognitionCommand; - private WaitCommand $waitCommand; + private OcrHttpClient $httpClient; private OcrRequestBuilder $requestBuilder; public function __construct( @@ -34,13 +32,7 @@ public function __construct( StreamFactoryInterface $streamFactory ) { $this->requestBuilder = new OcrRequestBuilder($credentials); - $httpClient = new OcrHttpClient($transport, $requestFactory, $streamFactory); - - $this->recognizeTextCommand = new RecognizeTextCommand($httpClient, $this->requestBuilder); - $this->startTextRecognitionCommand = new StartTextRecognitionCommand($httpClient, $this->requestBuilder); - $this->getOperationCommand = new GetOperationCommand($httpClient, $this->requestBuilder); - $this->getRecognitionCommand = new GetRecognitionCommand($httpClient, $this->requestBuilder); - $this->waitCommand = new WaitCommand($this->getOperationCommand, $this->getRecognitionCommand); + $this->httpClient = new OcrHttpClient($transport, $requestFactory, $streamFactory); } /** @@ -48,7 +40,12 @@ public function __construct( */ public function recognizeText(string $bytes, string $mime, array $options = []): OcrResponse { - return $this->recognizeTextCommand->execute($bytes, $mime, $options); + $payload = $this->requestBuilder->buildRecognizePayload($bytes, $mime, $options); + $headers = $this->requestBuilder->buildHeaders($options, true); + + [$data, $meta] = $this->httpClient->send(new TextRecognitionRequest($payload, $headers)); + + return new OcrResponse($data, $meta); } /** @@ -58,7 +55,7 @@ public function recognizeTextFromFile(string $path, array $options = []): OcrRes { [$bytes, $mime] = $this->requestBuilder->readFilePayload($path, $options); - return $this->recognizeTextCommand->execute($bytes, $mime, $options); + return $this->recognizeText($bytes, $mime, $options); } /** @@ -66,7 +63,17 @@ public function recognizeTextFromFile(string $path, array $options = []): OcrRes */ public function startTextRecognition(string $bytes, string $mime, array $options = []): OperationHandle { - return $this->startTextRecognitionCommand->execute($bytes, $mime, $options); + $payload = $this->requestBuilder->buildRecognizePayload($bytes, $mime, $options); + $headers = $this->requestBuilder->buildHeaders($options, true); + + [$data, $meta] = $this->httpClient->send(new TextRecognitionAsyncRequest($payload, $headers)); + + $operationId = $data['id'] ?? null; + if (!is_string($operationId) || $operationId === '') { + throw new ValidationException('Missing operation id in async recognition response.'); + } + + return new OperationHandle($operationId, $meta['request_id'] ?? null); } /** @@ -76,22 +83,71 @@ public function startTextRecognitionFromFile(string $path, array $options = []): { [$bytes, $mime] = $this->requestBuilder->readFilePayload($path, $options); - return $this->startTextRecognitionCommand->execute($bytes, $mime, $options); + return $this->startTextRecognition($bytes, $mime, $options); } public function getOperation(string $operationId): OperationStatus { - return $this->getOperationCommand->execute($operationId); + $headers = $this->requestBuilder->buildHeaders([], false); + $request = new OperationRequest($operationId, $headers); + + [$data, $meta] = $this->httpClient->send($request); + + return new OperationStatus($request->getOperationId(), (bool) ($data['done'] ?? false), $data, $meta); } public function getRecognition(string $operationId): OcrResponse { - return $this->getRecognitionCommand->execute($operationId); + $headers = $this->requestBuilder->buildHeaders([], false); + $request = new RecognitionRequest($operationId, $headers); + + [$data, $meta] = $this->httpClient->send($request); + + return new OcrResponse($data, $meta); } public function wait(string $operationId, int $timeoutSeconds = 60, ?BackoffPolicy $backoff = null): OcrResponse { - return $this->waitCommand->execute($operationId, $timeoutSeconds, $backoff); + $backoff = $backoff ?? new BackoffPolicy(); + $deadline = time() + max(0, $timeoutSeconds); + $attempt = 0; + + while (true) { + $status = $this->getOperation($operationId); + $payload = $status->getPayload(); + + if ($status->isDone()) { + $errorMessage = $this->extractOperationError($payload); + if ($errorMessage !== null) { + throw new ApiException($errorMessage); + } + + $response = $payload['response'] ?? null; + if (is_array($response)) { + return new OcrResponse($response, $status->getMeta()); + } + + return $this->getRecognition($operationId); + } + + $errorMessage = $this->extractOperationError($payload); + if ($errorMessage !== null) { + throw new ApiException($errorMessage); + } + + $now = time(); + if ($now >= $deadline) { + throw new TimeoutException('OCR operation timed out.'); + } + + $delay = $backoff->getDelayForAttempt($attempt); + $remaining = $deadline - $now; + $sleepSeconds = min($delay, $remaining); + if ($sleepSeconds > 0) { + sleep($sleepSeconds); + } + $attempt++; + } } /** @@ -114,4 +170,22 @@ public function waitMany( return $runner->run($tasks); } + /** + * @param array $payload + */ + private function extractOperationError(array $payload): ?string + { + $error = $payload['error'] ?? null; + if (!is_array($error)) { + return null; + } + + $message = $error['message'] ?? null; + if (is_string($message) && $message !== '') { + return $message; + } + + return 'OCR operation failed.'; + } + } diff --git a/src/Ocr/Request/OcrRequestInterface.php b/src/Ocr/Request/OcrRequestInterface.php new file mode 100644 index 0000000..ae5ad55 --- /dev/null +++ b/src/Ocr/Request/OcrRequestInterface.php @@ -0,0 +1,22 @@ + + */ + public function getHeaders(): array; + + /** + * @return array|null + */ + public function getBody(): ?array; +} diff --git a/src/Ocr/Request/OperationRequest.php b/src/Ocr/Request/OperationRequest.php new file mode 100644 index 0000000..f4b0406 --- /dev/null +++ b/src/Ocr/Request/OperationRequest.php @@ -0,0 +1,54 @@ + $headers + */ + public function __construct(string $operationId, private array $headers) + { + $operationId = trim($operationId); + if ($operationId === '') { + throw new ValidationException('Operation id must be a non-empty string.'); + } + + $this->operationId = $operationId; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getUrl(): string + { + return OcrEndpoints::OPERATION_BASE_URI . '/' . rawurlencode($this->operationId); + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + public function getBody(): ?array + { + return null; + } + + public function getOperationId(): string + { + return $this->operationId; + } +} diff --git a/src/Ocr/Request/RecognitionRequest.php b/src/Ocr/Request/RecognitionRequest.php new file mode 100644 index 0000000..29c5da2 --- /dev/null +++ b/src/Ocr/Request/RecognitionRequest.php @@ -0,0 +1,51 @@ + $headers + */ + public function __construct(string $operationId, private array $headers) + { + $operationId = trim($operationId); + if ($operationId === '') { + throw new ValidationException('Operation id must be a non-empty string.'); + } + + $this->operationId = $operationId; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getUrl(): string + { + $query = http_build_query(['operationId' => $this->operationId]); + + return OcrEndpoints::OCR_BASE_URI . '/getRecognition?' . $query; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + public function getBody(): ?array + { + return null; + } +} diff --git a/src/Ocr/Request/TextRecognitionAsyncRequest.php b/src/Ocr/Request/TextRecognitionAsyncRequest.php new file mode 100644 index 0000000..c3169a0 --- /dev/null +++ b/src/Ocr/Request/TextRecognitionAsyncRequest.php @@ -0,0 +1,44 @@ + $payload + * @param array $headers + */ + public function __construct(private array $payload, private array $headers) + { + } + + public function getMethod(): string + { + return 'POST'; + } + + public function getUrl(): string + { + return OcrEndpoints::OCR_BASE_URI . '/recognizeTextAsync'; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * @return array + */ + public function getBody(): array + { + return $this->payload; + } +} diff --git a/src/Ocr/Request/TextRecognitionRequest.php b/src/Ocr/Request/TextRecognitionRequest.php new file mode 100644 index 0000000..5a4f114 --- /dev/null +++ b/src/Ocr/Request/TextRecognitionRequest.php @@ -0,0 +1,44 @@ + $payload + * @param array $headers + */ + public function __construct(private array $payload, private array $headers) + { + } + + public function getMethod(): string + { + return 'POST'; + } + + public function getUrl(): string + { + return OcrEndpoints::OCR_BASE_URI . '/recognizeText'; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * @return array + */ + public function getBody(): array + { + return $this->payload; + } +} diff --git a/tests/OcrServiceTest.php b/tests/OcrServiceTest.php index 9e0505f..faa7f9b 100644 --- a/tests/OcrServiceTest.php +++ b/tests/OcrServiceTest.php @@ -40,6 +40,8 @@ public function testRecognizeTextBuildsPayloadAndHeaders(): void $request = $transport->getLastRequest(); self::assertNotNull($request); + self::assertSame('POST', $request->getMethod()); + self::assertSame('/ocr/v1/recognizeText', $request->getUri()->getPath()); self::assertSame('Api-Key test', $request->getHeaderLine('Authorization')); self::assertSame('folder-1', $request->getHeaderLine('x-folder-id')); self::assertSame('req-override', $request->getHeaderLine('x-request-id')); @@ -87,6 +89,11 @@ public function testStartTextRecognitionReturnsOperationHandle(): void self::assertSame('op-1', $handle->getOperationId()); self::assertSame('req-op', $handle->getRequestId()); + + $request = $transport->getLastRequest(); + self::assertNotNull($request); + self::assertSame('POST', $request->getMethod()); + self::assertSame('/ocr/v1/recognizeTextAsync', $request->getUri()->getPath()); } public function testGetOperationRequiresId(): void @@ -97,6 +104,14 @@ public function testGetOperationRequiresId(): void $service->getOperation(' '); } + public function testGetRecognitionRequiresId(): void + { + $service = $this->createService(new FakeTransport()); + + $this->expectException(ValidationException::class); + $service->getRecognition(' '); + } + public function testWaitReturnsResponseFromOperation(): void { $transport = new FakeTransport([