diff --git a/pyproject.toml b/pyproject.toml index f9ee93f..2bdc5de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pdfrest" -version = "1.0.3" +version = "1.1.0" description = "Python client library for interacting with the pdfRest API" readme = {file = "README.md", content-type = "text/markdown"} authors = [ diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 7c66e19..b06a306 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -956,7 +956,11 @@ def _extract_error_details( pdfrest_error = PdfRestErrorResponse.model_validate_json(response.content) except ValidationError: return None, response.text - return pdfrest_error.error, None + error_detail = pdfrest_error.error_detail + if error_detail is None: + return pdfrest_error.error, None + error_payload = error_detail.model_dump(by_alias=True, exclude_none=True) + return pdfrest_error.error, error_payload class _SyncApiClient(_BaseApiClient[httpx.Client]): diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index 2ed9563..62a5814 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -13,6 +13,8 @@ ExtractedTextWordStyle, ExtractTextResponse, PdfRestDeletionResponse, + PdfRestErrorDetail, + PdfRestErrorIssue, PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, @@ -37,6 +39,8 @@ "ExtractedTextWordFont", "ExtractedTextWordStyle", "PdfRestDeletionResponse", + "PdfRestErrorDetail", + "PdfRestErrorIssue", "PdfRestErrorResponse", "PdfRestFile", "PdfRestFileBasedResponse", diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 26ff77b..cef741f 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -40,6 +40,8 @@ "ExtractedTextWordFont", "ExtractedTextWordStyle", "PdfRestDeletionResponse", + "PdfRestErrorDetail", + "PdfRestErrorIssue", "PdfRestErrorResponse", "PdfRestFile", "PdfRestFileBasedResponse", @@ -246,9 +248,33 @@ class UpResponse(BaseModel): class PdfRestErrorResponse(BaseModel): """Error response payloads from pdfRest.""" - error: str | None = Field(alias="message") + error: str | None = Field(validation_alias=AliasChoices("error", "message")) """Human-readable error message returned by the API.""" + error_detail: PdfRestErrorDetail | None = Field(default=None, alias="errorDetail") + """Structured validation issues returned by the API when available.""" + + model_config = ConfigDict(extra="allow", frozen=True) + + +class PdfRestErrorIssue(BaseModel): + """Single issue entry inside an `errorDetail` response.""" + + path: str + message: str + minimum: int | float | None = None + maximum: int | float | None = None + expected: str | None = None + received: Any | None = None + + model_config = ConfigDict(extra="allow", frozen=True) + + +class PdfRestErrorDetail(BaseModel): + """Structured details for API validation errors.""" + + issues: list[PdfRestErrorIssue] = Field(default_factory=list) + model_config = ConfigDict(extra="allow", frozen=True) diff --git a/tests/test_client.py b/tests/test_client.py index c0380c9..d9b11cf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -55,6 +55,30 @@ def _build_file_info( } +def _build_error_detail_response() -> dict[str, Any]: + return { + "error": "-.8 is not within the acceptable range for opacity", + "errorDetail": { + "issues": [ + { + "path": "fields.text_objects[0].opacity", + "message": "Too small: expected number to be >=0", + "minimum": 0, + "maximum": 1, + }, + { + "path": "fields.text_objects[0].text_color_cmyk[2]", + "message": ( + 'Invalid text_color_cmyk yellow value "foo" (expected 0-100).' + ), + "minimum": 0, + "maximum": 100, + }, + ] + }, + } + + class NonSeekableByteStream(BytesIO): def __init__(self, payload: bytes) -> None: super().__init__(payload) @@ -939,6 +963,30 @@ def handler(_: httpx.Request) -> httpx.Response: assert exc_info.value.status_code == 500 +def test_client_preserves_structured_error_detail( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PDFREST_API_KEY", VALID_API_KEY) + error_payload = _build_error_detail_response() + + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(400, json=error_payload) + + transport = httpx.MockTransport(handler) + with ( + pytest.raises(PdfRestApiError, match=r"acceptable range for opacity") as exc, + PdfRestClient(transport=transport) as client, + ): + client.up() + + assert exc.value.status_code == 400 + assert exc.value.response_content == error_payload["errorDetail"] + assert isinstance(exc.value.response_content, dict) + issues = exc.value.response_content["issues"] + assert issues[0]["path"] == "fields.text_objects[0].opacity" + assert issues[1]["path"] == "fields.text_objects[0].text_color_cmyk[2]" + + @pytest.mark.asyncio async def test_async_client_up(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PDFREST_API_KEY", ASYNC_API_KEY) @@ -954,6 +1002,31 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.status == "OK" +@pytest.mark.asyncio +async def test_async_client_preserves_structured_error_detail( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PDFREST_API_KEY", ASYNC_API_KEY) + error_payload = _build_error_detail_response() + + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(400, json=error_payload) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(transport=transport) as client: + with pytest.raises( + PdfRestApiError, match=r"acceptable range for opacity" + ) as exc: + await client.up() + + assert exc.value.status_code == 400 + assert exc.value.response_content == error_payload["errorDetail"] + assert isinstance(exc.value.response_content, dict) + issues = exc.value.response_content["issues"] + assert issues[0]["path"] == "fields.text_objects[0].opacity" + assert issues[1]["path"] == "fields.text_objects[0].text_color_cmyk[2]" + + @pytest.mark.asyncio async def test_async_up_with_query_and_timeout( monkeypatch: pytest.MonkeyPatch, diff --git a/uv.lock b/uv.lock index ad017ec..24a7394 100644 --- a/uv.lock +++ b/uv.lock @@ -961,7 +961,7 @@ wheels = [ [[package]] name = "pdfrest" -version = "1.0.3" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "exceptiongroup" },