diff --git a/CHANGELOG.md b/CHANGELOG.md index 43184c8..b1c96fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `TangoValidationError` now exposes the API's structured validation details + directly: `.issues` (the list of `{"path": ..., "reason": ...}` entries the + server returns for shape errors) and `.available_fields` (the endpoint's + valid field set, when included). Both were previously reachable only by + digging through `.response_data`. ([#45](https://github.com/makegov/tango-python/issues/45)) + +### Changed +- 400 error messages now name the rejected field(s) and reason when the API + returns structured `issues` — e.g. + `Invalid request parameters: Invalid shape: tradeoff_process (unknown_field)` + instead of just `Invalid request parameters: Invalid shape`. ([#45](https://github.com/makegov/tango-python/issues/45)) + ## [1.2.0] - 2026-06-05 ### Added diff --git a/tango/client.py b/tango/client.py index 139c68d..a7f5720 100644 --- a/tango/client.py +++ b/tango/client.py @@ -183,6 +183,17 @@ def _request( ) if detail: error_msg = f"Invalid request parameters: {detail}" + issues = error_data.get("issues") + if isinstance(issues, list): + rejected = [ + f"{issue['path']} ({issue['reason']})" + if issue.get("reason") + else str(issue["path"]) + for issue in issues + if isinstance(issue, dict) and issue.get("path") + ] + if rejected: + error_msg = f"{error_msg}: {', '.join(rejected)}" raise TangoValidationError( error_msg, response.status_code, diff --git a/tango/exceptions.py b/tango/exceptions.py index a330f73..7b12344 100644 --- a/tango/exceptions.py +++ b/tango/exceptions.py @@ -33,7 +33,24 @@ class TangoNotFoundError(TangoAPIError): class TangoValidationError(TangoAPIError): """Request validation error""" - pass + @property + def issues(self) -> list[dict[str, Any]]: + """Structured validation issues from the API response. + + For shape errors the API returns entries like + ``{"path": "tradeoff_process", "reason": "unknown_field"}``. + Empty list when the response carried no structured issues. + """ + val = self.response_data.get("issues") + if not isinstance(val, list): + return [] + return [item for item in val if isinstance(item, dict)] + + @property + def available_fields(self) -> dict[str, Any] | None: + """The endpoint's valid field set, when the API includes one.""" + val = self.response_data.get("available_fields") + return val if isinstance(val, dict) else None class TangoRateLimitError(TangoAPIError): diff --git a/tests/test_client.py b/tests/test_client.py index adbea21..68645cd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1152,6 +1152,55 @@ def test_400_validation_error(self, mock_request): assert exc_info.value.status_code == 400 assert exc_info.value.response_data == {"error": "invalid params"} + @patch("tango.client.httpx.Client.request") + def test_400_structured_shape_error(self, mock_request): + """Test 400 with structured shape-error body surfaces issues and available_fields""" + body = { + "error": "Invalid shape", + "issues": [{"path": "fair_opportunity_limited_sources", "reason": "unknown_field"}], + "available_fields": {"fields": ["piid", "competition"]}, + } + mock_response = Mock() + mock_response.is_success = False + mock_response.status_code = 400 + mock_response.content = b"x" + mock_response.json.return_value = body + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + + with pytest.raises(TangoValidationError) as exc_info: + client.list_agencies() + + err = exc_info.value + assert str(err) == ( + "Invalid request parameters: Invalid shape: " + "fair_opportunity_limited_sources (unknown_field)" + ) + assert err.issues == [ + {"path": "fair_opportunity_limited_sources", "reason": "unknown_field"} + ] + assert err.available_fields == {"fields": ["piid", "competition"]} + assert err.response_data == body + + @patch("tango.client.httpx.Client.request") + def test_400_validation_error_issues_accessors_empty(self, mock_request): + """Test issues/available_fields accessors on a plain 400 body""" + mock_response = Mock() + mock_response.is_success = False + mock_response.status_code = 400 + mock_response.content = b'{"error": "invalid params"}' + mock_response.json.return_value = {"error": "invalid params"} + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + + with pytest.raises(TangoValidationError) as exc_info: + client.list_agencies() + + assert exc_info.value.issues == [] + assert exc_info.value.available_fields is None + @patch("tango.client.httpx.Client.request") def test_400_validation_error_no_content(self, mock_request): """Test 400 with no content"""