diff --git a/rest/python/server/integration_test.py b/rest/python/server/integration_test.py index 88de62d..f1436fa 100644 --- a/rest/python/server/integration_test.py +++ b/rest/python/server/integration_test.py @@ -1530,6 +1530,53 @@ def _body(**extra: object) -> dict: self.assertEqual(response.status_code, 201, f"Response: {response.text}") self.assertIsInstance(response.json().get("id"), str) + def test_validation_failure_answers_with_ucp_envelope(self) -> None: + """A validation failure answers with the UCP envelope, not detail.""" + with self.client: + response = self.client.post( + "/checkout-sessions", + headers=self._get_headers( + idempotency_key="val_err_1", request_id="val_err_1" + ), + json={"line_items": "not-an-array"}, + ) + self.assertEqual(response.status_code, 422) + self.assertIn( + "application/json", response.headers.get("content-type", "") + ) + data = response.json() + self.assertNotIn("detail", data, "flat detail shape must be gone") + self.assertEqual( + data.get("ucp", {}).get("status"), "error", "ucp.status must be 'error'" + ) + self.assertEqual(data.get("ucp", {}).get("version"), app.version) + messages = data.get("messages", []) + self.assertTrue( + isinstance(messages, list) and len(messages) > 0, + "messages[] must carry the failure", + ) + msg = messages[0] + self.assertEqual(msg.get("type"), "error") + self.assertEqual(msg.get("code"), "INVALID_REQUEST") + self.assertEqual(msg.get("severity"), "unrecoverable") + self.assertIn( + "line_items", + msg.get("content", ""), + "content must name the offending member", + ) + + def test_valid_create_succeeds_after_envelope_change(self) -> None: + """A valid checkout creation still succeeds after the change.""" + with self.client: + response = self.client.post( + "/checkout-sessions", + headers=self._get_headers( + idempotency_key="val_ok_1", request_id="val_ok_1" + ), + json={"line_items": [{"item": {"id": "rose"}, "quantity": 1}]}, + ) + self.assertEqual(response.status_code, 201, f"Response: {response.text}") + if __name__ == "__main__": absltest.main() diff --git a/rest/python/server/server.py b/rest/python/server/server.py index 69d290f..2d828b1 100644 --- a/rest/python/server/server.py +++ b/rest/python/server/server.py @@ -22,6 +22,7 @@ from exceptions import UcpError from fastapi import FastAPI from fastapi import Request +from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse import generated_routes.ucp_routes from routes.discovery import router as discovery_router @@ -43,6 +44,57 @@ ) +def _format_validation_loc(loc: tuple[int | str, ...]) -> str: + parts = list(loc) + if parts and parts[0] in ("body", "query", "path", "header"): + parts = parts[1:] + if not parts: + return str(loc[0]) if loc else "request" + path = "" + for p in parts: + if isinstance(p, int): + path += f"[{p}]" + else: + path = f"{path}.{p}" if path else str(p) + return path + + +@app.exception_handler(RequestValidationError) +async def request_validation_exception_handler( + request: Request, exc: RequestValidationError +): + """Handle validation errors and convert to the UCP error envelope.""" + del request # Unused. + error_lines = [] + for err in exc.errors(): + path = _format_validation_loc(err.get("loc", ())) + msg = err.get("msg", "Validation error") + error_lines.append(f"✖ {msg}\n → at {path}") + + error_content = ( + "\n".join(error_lines) if error_lines else "Request validation failed." + ) + logger.warning("Request payload failed validation:\n%s", error_content) + + return JSONResponse( + status_code=422, + content={ + "ucp": { + "version": config.get_server_version(), + "status": "error", + }, + "messages": [ + { + "type": "error", + "code": "INVALID_REQUEST", + "content": error_content, + "severity": "unrecoverable", + } + ], + }, + ) + + @app.exception_handler(UcpError) async def ucp_exception_handler(request: Request, exc: UcpError): """Handle UCP-specific exceptions and converts them to JSON responses."""