diff --git a/src/Error/ErrorItem.php b/src/Error/ErrorItem.php new file mode 100644 index 00000000..fc2e96d8 --- /dev/null +++ b/src/Error/ErrorItem.php @@ -0,0 +1,27 @@ +detail = $rawResponse['detail']; + $this->pointer = $rawResponse['pointer']; + } +} diff --git a/src/Error/MindeeV2HttpException.php b/src/Error/MindeeV2HttpException.php index 898435c2..d8146fba 100644 --- a/src/Error/MindeeV2HttpException.php +++ b/src/Error/MindeeV2HttpException.php @@ -2,6 +2,8 @@ namespace Mindee\Error; +use Mindee\Parsing\V2\ErrorResponse; + /** * Exceptions relating to HTTP errors for the V2 API. */ @@ -15,15 +17,30 @@ class MindeeV2HttpException extends MindeeException * @var string|null Details on the exception. */ public ?string $detail; + /** + * @var string|null Title of the error. + */ + public ?string $title; + /** + * @var string|null Error code. + * Note: PHP's `RuntimeException` class uses `$code` for the error code. + */ + public ?string $errorCode; + /** + * @var array List of associated errors. + */ + public array $errors; /** - * @param integer $status HTTP status code, defaults to -1 if not set. - * @param string|null $detail Optional details on the exception. + * @param ErrorResponse $response Server Error response. */ - public function __construct(int $status, string $detail = null) + public function __construct(ErrorResponse $response) { - parent::__construct("HTTP Error $status - $detail"); - $this->status = $status; - $this->detail = $detail; + parent::__construct("HTTP $response->status - $response->title :: $response->code - $response->detail"); + $this->status = $response->status; + $this->detail = $response->detail; + $this->errorCode = $response->code; + $this->title = $response->title; + $this->errors = $response->errors; } } diff --git a/src/Error/MindeeV2HttpUnknownError.php b/src/Error/MindeeV2HttpUnknownError.php new file mode 100644 index 00000000..e2ec4a85 --- /dev/null +++ b/src/Error/MindeeV2HttpUnknownError.php @@ -0,0 +1,28 @@ + -1, + "detail" => "Couldn't deserialize server error. Found: $response", + "title" => "Unknown error", + "code" => "000-000" + ] + ) + ); + } +} diff --git a/src/Http/MindeeApiV2.php b/src/Http/MindeeApiV2.php index d0a77cd1..cce82496 100644 --- a/src/Http/MindeeApiV2.php +++ b/src/Http/MindeeApiV2.php @@ -12,13 +12,16 @@ // phpcs:disable include_once(dirname(__DIR__) . '/version.php'); + // phpcs:enable use Mindee\Error\MindeeV2HttpException; +use Mindee\Error\MindeeV2HttpUnknownError; use Mindee\Input\InferenceParameters; use Mindee\Input\InputSource; use Mindee\Input\LocalInputSource; use Mindee\Input\URLInputSource; +use Mindee\Parsing\V2\ErrorResponse; use Mindee\Parsing\V2\InferenceResponse; use Mindee\Parsing\V2\JobResponse; @@ -68,6 +71,7 @@ private function getUserAgent(): string } return 'mindee-api-php@v' . VERSION . ' php-v' . PHP_VERSION . ' ' . $os; } + /** * @var string|null API key. */ @@ -164,8 +168,9 @@ public function reqPostInferenceEnqueue(InputSource $inputDoc, InferenceParamete * @return JobResponse|InferenceResponse The processed response object. * @throws MindeeException Throws if HTTP status indicates an error or deserialization fails. * @throws MindeeV2HttpException Throws if the HTTP status indicates an error. + * @throws MindeeV2HttpUnknownError Throws if the server sends an unexpected reply. */ - private function processResponse(array $result, string $responseType) + private function processResponse(array $result, string $responseType): InferenceResponse|JobResponse { $statusCode = $result['code'] ?? -1; @@ -173,14 +178,9 @@ private function processResponse(array $result, string $responseType) $responseData = json_decode($result['data'], true); if ($responseData && isset($responseData['status'])) { - throw new MindeeV2HttpException( - $responseData['status'], - $responseData['detail'] ?? 'Unknown error.' - ); + throw new MindeeV2HttpException(new ErrorResponse($responseData)); } - - $detail = $responseData && $responseData['detail'] ? $responseData['detail'] : 'Unknown Error'; - throw new MindeeException($result['code'] ?? -1, $detail); + throw new MindeeV2HttpUnknownError(json_encode($result, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); } try { @@ -321,6 +321,19 @@ private function documentEnqueuePost( if (isset($params->rag)) { $postFields['rag'] = $params->rag ? 'true' : 'false'; } + if (isset($params->webhooksIds) && count($params->webhooksIds) > 0) { + if (PHP_VERSION_ID < 80200 && count($params->webhooksIds) > 1) { + # NOTE: see https://bugs.php.net/bug.php?id=51634 + error_log("PHP version is too low to support webbook array destructuring. + \nOnly the first webhook ID will be sent to the server."); + $postFields['webhook_ids'] = $params->webhooksIds[0]; + } else { + $postFields['webhook_ids'] = $params->webhooksIds; + } + } + if (isset($params->alias)) { + $postFields['alias'] = $params->alias; + } $url = $this->baseUrl . '/inferences/enqueue'; curl_setopt($ch, CURLOPT_URL, $url); diff --git a/src/Input/LocalInputSource.php b/src/Input/LocalInputSource.php index a2dce14c..a1c26633 100644 --- a/src/Input/LocalInputSource.php +++ b/src/Input/LocalInputSource.php @@ -277,8 +277,8 @@ public function fixPDF(): void */ public function compress( int $quality = 85, - int $maxWidth = null, - int $maxHeight = null, + ?int $maxWidth = null, + ?int $maxHeight = null, bool $forceSourceTextCompression = false, bool $disableSourceText = true ): void { diff --git a/src/Input/LocalResponse.php b/src/Input/LocalResponse.php index 9df33843..81e037f8 100644 --- a/src/Input/LocalResponse.php +++ b/src/Input/LocalResponse.php @@ -125,7 +125,7 @@ public function isValidHMACSignature(string $secretKey, string $signature): bool * @return mixed An instance of responseClass populated with the file content. * @throws MindeeException If the provided class cannot be instantiated. */ - public function deserializeResponse(string $responseClass) + public function deserializeResponse(string $responseClass): mixed { try { $data = $this->toArray(); diff --git a/src/Parsing/V2/ErrorResponse.php b/src/Parsing/V2/ErrorResponse.php index 5362c680..7190621e 100644 --- a/src/Parsing/V2/ErrorResponse.php +++ b/src/Parsing/V2/ErrorResponse.php @@ -2,21 +2,37 @@ namespace Mindee\Parsing\V2; +use Mindee\Error\ErrorItem; + /** * Error response class. */ class ErrorResponse { /** - * @var integer HTTP Status code. + * @var integer The HTTP status code returned by the server. */ public int $status; /** - * @var string The detail on the error. + * @var string A human-readable explanation specific to the occurrence of the problem. */ public string $detail; + /** + * @var string|null A short, human-readable summary of the problem. + */ + public ?string $title; + /** + * @var string|null A machine-readable code specific to the occurrence of the problem. + * Note: PHP's `RuntimeException` class uses `$code` for the error code. + */ + public ?string $code; + /** + * @var array|mixed|null A list of explicit error details. + */ + public ?array $errors; + /** * @param array $serverResponse Raw server response array. */ @@ -24,5 +40,14 @@ public function __construct(array $serverResponse) { $this->status = $serverResponse['status']; $this->detail = $serverResponse['detail']; + $this->title = $serverResponse['title'] ?? null; + $this->code = $serverResponse['code'] ?? null; + if (isset($serverResponse['errors']) && is_array($serverResponse['errors'])) { + $this->errors = array_map(static function ($error) { + return new ErrorItem($error); + }, $serverResponse['errors']); + } else { + $this->errors = []; + } } } diff --git a/src/Parsing/V2/InferenceResult.php b/src/Parsing/V2/InferenceResult.php index 8d879c43..78ccaabd 100644 --- a/src/Parsing/V2/InferenceResult.php +++ b/src/Parsing/V2/InferenceResult.php @@ -19,6 +19,11 @@ class InferenceResult */ public ?RawText $rawText; + /** + * @var RagMetadata|null RAG metadata. + */ + public ?RagMetadata $rag; + /** * @param array $serverResponse Raw server response array. */ @@ -28,6 +33,9 @@ public function __construct(array $serverResponse) $this->rawText = isset($serverResponse['raw_text']) ? new RawText($serverResponse['raw_text']) : null; + $this->rag = isset( + $serverResponse['rag'] + ) ? new RagMetadata($serverResponse['rag']) : null; } /** diff --git a/src/Parsing/V2/RagMetadata.php b/src/Parsing/V2/RagMetadata.php new file mode 100644 index 00000000..a2dec186 --- /dev/null +++ b/src/Parsing/V2/RagMetadata.php @@ -0,0 +1,22 @@ +retrievedDocumentId = $rawResponse['retrieved_document_id'] ?? null; + } +} diff --git a/tests/ClientV2Test.php b/tests/V2/ClientV2Test.php similarity index 89% rename from tests/ClientV2Test.php rename to tests/V2/ClientV2Test.php index 428d2777..af7f6fe5 100644 --- a/tests/ClientV2Test.php +++ b/tests/V2/ClientV2Test.php @@ -1,5 +1,7 @@ createMock(MindeeApiV2::class); - $syntheticResponse = file_get_contents(__DIR__ . '/resources/v2/job/ok_processing.json'); + $syntheticResponse = file_get_contents(\TestingUtilities::getV2DataDir() . '/job/ok_processing.json'); $predictable->expects($this->once()) ->method('reqPostInferenceEnqueue') ->with( @@ -38,7 +39,7 @@ public function testEnqueuePostAsync(): void $mindeeClient = self::makeClientWithMockedApi($predictable); - $input = new PathInput(__DIR__ . '/resources/file_types/pdf/blank_1.pdf'); + $input = new PathInput(\TestingUtilities::getFileTypesDir() . '/pdf/blank_1.pdf'); $params = new InferenceParameters('dummy-model-id'); $response = $mindeeClient->enqueueInference($input, $params); @@ -52,7 +53,7 @@ public function testDocumentGetJobAsync(): void /** @var MindeeApiV2&MockObject $predictable */ $predictable = $this->createMock(MindeeApiV2::class); - $syntheticResponse = file_get_contents(__DIR__ . '/resources/v2/job/ok_processing.json'); + $syntheticResponse = file_get_contents(\TestingUtilities::getV2DataDir() . '/job/ok_processing.json'); $processing = new JobResponse(json_decode($syntheticResponse, true)); $predictable->expects($this->once()) @@ -73,7 +74,7 @@ public function testDocumentGetInferenceAsync(): void /** @var MindeeApiV2&MockObject $predictable */ $predictable = $this->createMock(MindeeApiV2::class); - $jsonFile = __DIR__ . '/resources/v2/products/financial_document/complete.json'; + $jsonFile = \TestingUtilities::getV2DataDir() . '/products/financial_document/complete.json'; $this->assertFileExists($jsonFile, 'Test resource file must exist'); $json = json_decode(file_get_contents($jsonFile), true); @@ -108,7 +109,7 @@ public function testDocumentGetInferenceAsync(): void public function testInferenceLoadsLocally(): void { - $jsonFile = __DIR__ . '/resources/v2/products/financial_document/complete.json'; + $jsonFile = \TestingUtilities::getV2DataDir() . '/products/financial_document/complete.json'; $this->assertFileExists($jsonFile, 'Test resource file must exist'); $localResponse = new LocalResponse($jsonFile); diff --git a/tests/ClientV2TestFunctional.php b/tests/V2/ClientV2TestFunctional.php similarity index 62% rename from tests/ClientV2TestFunctional.php rename to tests/V2/ClientV2TestFunctional.php index e5cc8a91..bf6f91f3 100644 --- a/tests/ClientV2TestFunctional.php +++ b/tests/V2/ClientV2TestFunctional.php @@ -1,6 +1,7 @@ - modelId, rag: false, rawText: true); $response = $this->mindeeClient->enqueueAndGetInference($source, $inferenceParams); @@ -59,7 +60,7 @@ public function testParseFileEmptyMultiPageMustSucceed(): void public function testParseFileFilledSinglePageMustSucceed(): void { $source = new PathInput( - TestingUtilities::getV1DataDir() . '/products/financial_document/default_sample.jpg' + \TestingUtilities::getV1DataDir() . '/products/financial_document/default_sample.jpg' ); $inferenceParams = new InferenceParameters($this->modelId, rag: false); @@ -90,24 +91,70 @@ public function testParseFileFilledSinglePageMustSucceed(): void ); } - public function testInvalidModelMustThrowError(): void + public function testInvalidUUIDMustThrowError(): void { - $source = new PathInput(__DIR__ . '/resources/file_types/pdf/multipage_cut-2.pdf'); + + $source = new PathInput(\TestingUtilities::getFileTypesDir() . '/pdf/blank_1.pdf'); $inferenceParams = new InferenceParameters('INVALID MODEL ID'); - $this->expectException(MindeeV2HttpException::class); - $this->expectExceptionMessage('422'); + try { + $this->mindeeClient->enqueueInference($source, $inferenceParams); + } catch (MindeeV2HttpException $e) { + $this->assertStringStartsWith('422-', $e->errorCode); + $this->assertNotEmpty($e->title); + $this->assertIsArray($e->errors); + } + } + + public function testUnknownModelMustThrowError(): void + { + $source = new PathInput(\TestingUtilities::getFileTypesDir() . '/pdf/multipage_cut-2.pdf'); + + $inferenceParams = new InferenceParameters('fc405e37-4ba4-4d03-aeba-533a8d1f0f21'); - $this->mindeeClient->enqueueInference($source, $inferenceParams); + try { + $this->mindeeClient->enqueueInference($source, $inferenceParams); + } catch (MindeeV2HttpException $e) { + $this->assertStringStartsWith('404-', $e->errorCode); + $this->assertNotEmpty($e->title); + $this->assertIsArray($e->errors); + } } + public function testInvalidJobMustThrowError(): void { - $this->expectException(MindeeV2HttpException::class); - $this->expectExceptionMessage('422'); + try { + $this->mindeeClient->getInference('fc405e37-4ba4-4d03-aeba-533a8d1f0f21'); + } catch (MindeeV2HttpException $e) { + $this->assertStringStartsWith('404-', $e->errorCode); + $this->assertNotEmpty($e->title); + $this->assertIsArray($e->errors); + } + } + + public function testInvalidWebhookIDsMustThrowError() + { + $source = new PathInput(\TestingUtilities::getFileTypesDir() . '/pdf/multipage_cut-2.pdf'); + + $inferenceParams = new InferenceParameters( + $this->modelId, + null, + null, + null, + null, + null, + ['fc405e37-4ba4-4d03-aeba-533a8d1f0f21', 'fc405e37-4ba4-4d03-aeba-533a8d1f0f21'] + ); - $this->mindeeClient->getInference('not-a-valid-job-ID'); + try { + $this->mindeeClient->enqueueInference($source, $inferenceParams); + } catch (MindeeV2HttpException $e) { + $this->assertStringStartsWith('422-', $e->errorCode); + $this->assertNotEmpty($e->title); + $this->assertIsArray($e->errors); + } } public function testUrlInputSourceMustNotRaiseErrors(): void diff --git a/tests/V2/InferenceTest.php b/tests/V2/Parsing/InferenceResponseTest.php similarity index 88% rename from tests/V2/InferenceTest.php rename to tests/V2/Parsing/InferenceResponseTest.php index bf04c37f..90d6ea81 100644 --- a/tests/V2/InferenceTest.php +++ b/tests/V2/Parsing/InferenceResponseTest.php @@ -1,23 +1,26 @@ assertFileExists($fullPath, "Resource file must exist: $path"); + $this->assertFileExists($path, "Resource file must exist: $path"); - return file_get_contents($fullPath); + return file_get_contents($path); } /** @@ -292,7 +294,7 @@ public function testRawTextsMustBeAccessible(): void $this->assertEquals('This is the raw text of the first page...', $first->content); foreach ($rawText->pages as $page) { - $this->assertEquals('string', gettype($page->content)); + $this->assertIsString($page->content); } } @@ -301,8 +303,10 @@ public function testRawTextsMustBeAccessible(): void */ public function testRstDisplayMustBeAccessible(): void { - $response = $this->loadFromResource('/v2/inference/standard_field_types.json'); - $expectedRst = $this->readFileAsString('/v2/inference/standard_field_types.rst'); + $response = $this->loadFromResource('v2/inference/standard_field_types.json'); + $expectedRst = $this->readFileAsString( + \TestingUtilities::getV2DataDir() . '/inference/standard_field_types.rst' + ); $inference = $response->inference; $this->assertNotNull($inference); $this->assertEquals($expectedRst, strval($response->inference)); @@ -358,4 +362,32 @@ public function testCoordinatesAndLocationDataMustBeAccessible(): void $this->assertTrue(FieldConfidence::Low->lt($dateField->confidence)); $this->assertEquals('Medium', $dateField->confidence->value); } + + public function testRagMetadataWhenMatched() + { + $response = $this->loadFromResource('v2/inference/rag_matched.json'); + $inference = $response->inference; + $this->assertNotNull($inference); + $this->assertEquals('12345abc-1234-1234-1234-123456789abc', $inference->result->rag->retrievedDocumentId); + } + + public function testRagMetadataWhenNotMatched() + { + $response = $this->loadFromResource('v2/inference/rag_not_matched.json'); + $inference = $response->inference; + $this->assertNotNull($inference); + $this->assertNull($inference->result->rag->retrievedDocumentId); + } + + public function testShouldLoadWith422Error() + { + $jsonResponse = json_decode(file_get_contents(\TestingUtilities::getV2DataDir() . '/job/fail_422.json'), true); + $response = new JobResponse($jsonResponse); + $this->assertNotNull($response->job); + $this->assertInstanceOf(ErrorResponse::class, $response->job->error); + $this->assertEquals(422, $response->job->error->status); + $this->assertStringStartsWith("422-", $response->job->error->code); + $this->assertEquals(1, count($response->job->error->errors)); + $this->assertInstanceOf(ErrorItem::class, $response->job->error->errors[0]); + } } diff --git a/tests/V2/Parsing/JobResponseTest.php b/tests/V2/Parsing/JobResponseTest.php new file mode 100644 index 00000000..b3d9bbc7 --- /dev/null +++ b/tests/V2/Parsing/JobResponseTest.php @@ -0,0 +1 @@ +