From bb06a14ff55d53fbc0490549d7b08231446d7cda Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 16 Jul 2026 19:52:26 +0200 Subject: [PATCH 1/2] feat: expose documented API error codes --- README.es.md | 7 +- README.md | 7 +- VERSION.md | 4 +- composer.json | 5 +- src/ErrorCodes.php | 279 ++++++++++++++++++++++++++ src/Exceptions/FacturapiException.php | 11 + src/Facturapi.php | 1 + tests/Http/ErrorHandlingTest.php | 17 +- 8 files changed, 315 insertions(+), 16 deletions(-) create mode 100644 src/ErrorCodes.php diff --git a/README.es.md b/README.es.md index 0a89ee8..5d346e9 100644 --- a/README.es.md +++ b/README.es.md @@ -175,18 +175,23 @@ En respuestas no-2xx, el SDK lanza `Facturapi\Exceptions\FacturapiException`. La excepción incluye: - `getMessage()`: mensaje del API cuando está disponible. - `getStatusCode()`: código HTTP. +- `getApiErrorCode()`: código raíz documentado de V2 cuando está disponible. - `getErrorData()`: payload JSON decodificado del error (shape completo del API). - `getRawBody()`: cuerpo crudo de la respuesta. ```php use Facturapi\Exceptions\FacturapiException; +use Facturapi\RequestErrorCode; try { $facturapi->Invoices->create($payload); } catch (FacturapiException $e) { $status = $e->getStatusCode(); $error = $e->getErrorData(); // Shape completo del error del API cuando el body es JSON válido. - $firstDetail = $error['details'][0] ?? null; // p.ej. ['path' => 'items.0.quantity', 'message' => '...', 'code' => '...'] + $firstDetail = $error['errors'][0] ?? null; // p.ej. ['path' => 'items.0.quantity', 'message' => '...', 'code' => '...'] + $retryAfter = $e->getApiErrorCode() === RequestErrorCode::RATE_LIMIT_EXCEEDED + ? ($e->getResponseHeaders()['retry-after'] ?? null) + : null; } ``` diff --git a/README.md b/README.md index c5ffd4f..3dd5bd0 100644 --- a/README.md +++ b/README.md @@ -175,18 +175,23 @@ On non-2xx responses, the SDK throws `Facturapi\Exceptions\FacturapiException`. The exception includes: - `getMessage()`: API error message when present. - `getStatusCode()`: HTTP status code. +- `getApiErrorCode()`: documented V2 root error code when present. - `getErrorData()`: decoded JSON error payload (full API shape). - `getRawBody()`: raw response body string. ```php use Facturapi\Exceptions\FacturapiException; +use Facturapi\RequestErrorCode; try { $facturapi->Invoices->create($payload); } catch (FacturapiException $e) { $status = $e->getStatusCode(); $error = $e->getErrorData(); // Full API error shape when body is valid JSON. - $firstDetail = $error['details'][0] ?? null; // e.g. ['path' => 'items.0.quantity', 'message' => '...', 'code' => '...'] + $firstDetail = $error['errors'][0] ?? null; // e.g. ['path' => 'items.0.quantity', 'message' => '...', 'code' => '...'] + $retryAfter = $e->getApiErrorCode() === RequestErrorCode::RATE_LIMIT_EXCEEDED + ? ($e->getResponseHeaders()['retry-after'] ?? null) + : null; } ``` diff --git a/VERSION.md b/VERSION.md index ff3a77f..0516f07 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1,4 +1,6 @@ -4.5.0 +4.6.0 ## Added +- Add grouped constants for documented API root error codes and Facturapi-owned validation detail codes. +- Add `FacturapiException::getApiErrorCode()` for the string V2 root error code while preserving `getErrorCode()` compatibility. - Expose structured API error metadata on `FacturapiException`, including `code`, `path`, `location`, `errors`, `logId`, and response headers. diff --git a/composer.json b/composer.json index a34b01e..123c797 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,10 @@ "autoload": { "psr-4": { "Facturapi\\": "src/" - } + }, + "files": [ + "src/ErrorCodes.php" + ] }, "minimum-stability": "stable" } diff --git a/src/ErrorCodes.php b/src/ErrorCodes.php new file mode 100644 index 0000000..9c8eaa1 --- /dev/null +++ b/src/ErrorCodes.php @@ -0,0 +1,279 @@ +errorData) ? ($this->errorData['code'] ?? null) : null; } + /** + * Returns the documented V2 API root error code when the response contains one. + * getErrorCode() remains available for compatibility with older API responses. + */ + public function getApiErrorCode(): ?string + { + return is_array($this->errorData) && is_string($this->errorData['code'] ?? null) + ? $this->errorData['code'] + : null; + } + public function getErrorPath(): ?string { return is_array($this->errorData) && is_string($this->errorData['path'] ?? null) diff --git a/src/Facturapi.php b/src/Facturapi.php index b235f03..da26dfa 100644 --- a/src/Facturapi.php +++ b/src/Facturapi.php @@ -6,6 +6,7 @@ // Composer/PSR-4 users can continue relying on autoloading. require_once __DIR__ . '/Http/BaseClient.php'; require_once __DIR__ . '/Exceptions/FacturapiException.php'; +require_once __DIR__ . '/ErrorCodes.php'; require_once __DIR__ . '/Exceptions/Facturapi_Exception.php'; require_once __DIR__ . '/InvoiceRelation.php'; require_once __DIR__ . '/InvoiceType.php'; diff --git a/tests/Http/ErrorHandlingTest.php b/tests/Http/ErrorHandlingTest.php index d22e7d2..250090b 100644 --- a/tests/Http/ErrorHandlingTest.php +++ b/tests/Http/ErrorHandlingTest.php @@ -5,6 +5,7 @@ namespace Facturapi\Tests\Http; use Facturapi\Exceptions\FacturapiException; +use Facturapi\RequestErrorCode; use Facturapi\Resources\Invoices; use Facturapi\Tests\Support\FakeHttpClient; use GuzzleHttp\Psr7\Response; @@ -16,16 +17,9 @@ public function testApiErrorShapeIsFullyAvailableOnException(): void { $errorBody = [ 'message' => 'Request validation failed', - 'code' => 'validation_error', + 'code' => 'invalid_request', 'path' => 'customer.tax_id', 'location' => 'body', - 'details' => [ - [ - 'path' => 'customer.tax_id', - 'message' => 'customer.tax_id must be a valid RFC', - 'code' => 'invalid_rfc', - ], - ], 'errors' => [ [ 'path' => 'customer.tax_id', @@ -56,16 +50,15 @@ public function testApiErrorShapeIsFullyAvailableOnException(): void self::assertSame($errorBody, $exception->getResponseData()); self::assertSame(json_encode($errorBody), $exception->getRawBody()); - self::assertSame('validation_error', $exception->getErrorData()['code']); - self::assertSame('validation_error', $exception->getErrorCode()); + self::assertSame(RequestErrorCode::INVALID_REQUEST, $exception->getErrorData()['code']); + self::assertSame(RequestErrorCode::INVALID_REQUEST, $exception->getErrorCode()); + self::assertSame(RequestErrorCode::INVALID_REQUEST, $exception->getApiErrorCode()); self::assertSame('customer.tax_id', $exception->getErrorPath()); self::assertSame('body', $exception->getErrorLocation()); self::assertSame($errorBody['errors'], $exception->getErrors()); self::assertSame('log_123', $exception->getLogId()); self::assertSame('3', $exception->getResponseHeaders()['retry-after']); self::assertSame('log_123', $exception->getResponseHeaders()['x-facturapi-log-id']); - self::assertSame('customer.tax_id', $exception->getErrorData()['details'][0]['path']); - self::assertSame('invalid_rfc', $exception->getErrorData()['details'][0]['code']); } } From 511c66693bc94011546763c9a3db949732dd6f86 Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 16 Jul 2026 20:09:27 +0200 Subject: [PATCH 2/2] fix: return API error codes as strings --- README.es.md | 4 ++-- README.md | 4 ++-- VERSION.md | 2 +- src/Exceptions/FacturapiException.php | 11 +---------- tests/Http/ErrorHandlingTest.php | 8 +++++++- 5 files changed, 13 insertions(+), 16 deletions(-) diff --git a/README.es.md b/README.es.md index 5d346e9..66f0b11 100644 --- a/README.es.md +++ b/README.es.md @@ -175,7 +175,7 @@ En respuestas no-2xx, el SDK lanza `Facturapi\Exceptions\FacturapiException`. La excepción incluye: - `getMessage()`: mensaje del API cuando está disponible. - `getStatusCode()`: código HTTP. -- `getApiErrorCode()`: código raíz documentado de V2 cuando está disponible. +- `getErrorCode()`: código raíz documentado de V2 cuando está disponible. - `getErrorData()`: payload JSON decodificado del error (shape completo del API). - `getRawBody()`: cuerpo crudo de la respuesta. @@ -189,7 +189,7 @@ try { $status = $e->getStatusCode(); $error = $e->getErrorData(); // Shape completo del error del API cuando el body es JSON válido. $firstDetail = $error['errors'][0] ?? null; // p.ej. ['path' => 'items.0.quantity', 'message' => '...', 'code' => '...'] - $retryAfter = $e->getApiErrorCode() === RequestErrorCode::RATE_LIMIT_EXCEEDED + $retryAfter = $e->getErrorCode() === RequestErrorCode::RATE_LIMIT_EXCEEDED ? ($e->getResponseHeaders()['retry-after'] ?? null) : null; } diff --git a/README.md b/README.md index 3dd5bd0..7aec1aa 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ On non-2xx responses, the SDK throws `Facturapi\Exceptions\FacturapiException`. The exception includes: - `getMessage()`: API error message when present. - `getStatusCode()`: HTTP status code. -- `getApiErrorCode()`: documented V2 root error code when present. +- `getErrorCode()`: documented V2 root error code when present. - `getErrorData()`: decoded JSON error payload (full API shape). - `getRawBody()`: raw response body string. @@ -189,7 +189,7 @@ try { $status = $e->getStatusCode(); $error = $e->getErrorData(); // Full API error shape when body is valid JSON. $firstDetail = $error['errors'][0] ?? null; // e.g. ['path' => 'items.0.quantity', 'message' => '...', 'code' => '...'] - $retryAfter = $e->getApiErrorCode() === RequestErrorCode::RATE_LIMIT_EXCEEDED + $retryAfter = $e->getErrorCode() === RequestErrorCode::RATE_LIMIT_EXCEEDED ? ($e->getResponseHeaders()['retry-after'] ?? null) : null; } diff --git a/VERSION.md b/VERSION.md index 0516f07..5ffcdcb 100644 --- a/VERSION.md +++ b/VERSION.md @@ -2,5 +2,5 @@ ## Added - Add grouped constants for documented API root error codes and Facturapi-owned validation detail codes. -- Add `FacturapiException::getApiErrorCode()` for the string V2 root error code while preserving `getErrorCode()` compatibility. +- Fix `FacturapiException::getErrorCode()` to return only documented string API root codes. - Expose structured API error metadata on `FacturapiException`, including `code`, `path`, `location`, `errors`, `logId`, and response headers. diff --git a/src/Exceptions/FacturapiException.php b/src/Exceptions/FacturapiException.php index 7c56081..2bfb798 100644 --- a/src/Exceptions/FacturapiException.php +++ b/src/Exceptions/FacturapiException.php @@ -48,16 +48,7 @@ public function getRawBody(): ?string return $this->rawBody; } - public function getErrorCode(): mixed - { - return is_array($this->errorData) ? ($this->errorData['code'] ?? null) : null; - } - - /** - * Returns the documented V2 API root error code when the response contains one. - * getErrorCode() remains available for compatibility with older API responses. - */ - public function getApiErrorCode(): ?string + public function getErrorCode(): ?string { return is_array($this->errorData) && is_string($this->errorData['code'] ?? null) ? $this->errorData['code'] diff --git a/tests/Http/ErrorHandlingTest.php b/tests/Http/ErrorHandlingTest.php index 250090b..2efaee8 100644 --- a/tests/Http/ErrorHandlingTest.php +++ b/tests/Http/ErrorHandlingTest.php @@ -52,7 +52,6 @@ public function testApiErrorShapeIsFullyAvailableOnException(): void self::assertSame(RequestErrorCode::INVALID_REQUEST, $exception->getErrorData()['code']); self::assertSame(RequestErrorCode::INVALID_REQUEST, $exception->getErrorCode()); - self::assertSame(RequestErrorCode::INVALID_REQUEST, $exception->getApiErrorCode()); self::assertSame('customer.tax_id', $exception->getErrorPath()); self::assertSame('body', $exception->getErrorLocation()); self::assertSame($errorBody['errors'], $exception->getErrors()); @@ -82,4 +81,11 @@ public function testNonJsonErrorsStillExposeRawBody(): void self::assertSame($rawBody, $exception->getRawBody()); } } + + public function testNumericRootErrorCodeIsIgnored(): void + { + $exception = new FacturapiException(errorData: ['code' => 400]); + + self::assertNull($exception->getErrorCode()); + } }