diff --git a/backend/api_contract.py b/backend/api_contract.py new file mode 100644 index 00000000..4a54ca18 --- /dev/null +++ b/backend/api_contract.py @@ -0,0 +1,132 @@ +"""Backend API contract helpers. + +Provides request validation and error response construction based on the +Tent of Trials OpenAPI 3.1.0 specification. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +class ContractError(Exception): + """Raised when a request violates the API contract.""" + + def __init__(self, code: int, message: str, details: Optional[Dict[str, Any]] = None): + self.code = code + self.message = message + self.details = details or {} + super().__init__(message) + + def to_response(self) -> Dict[str, Any]: + body: Dict[str, Any] = {"code": self.code, "message": self.message} + if self.details: + body["details"] = self.details + return body + + +@dataclass +class RequestSpec: + """Describes the expected shape of an incoming request.""" + + required_fields: List[str] = field(default_factory=list) + optional_fields: List[str] = field(default_factory=list) + field_types: Dict[str, type] = field(default_factory=dict) + patterns: Dict[str, str] = field(default_factory=dict) + + +def validate_payload(payload: Dict[str, Any], spec: RequestSpec) -> None: + """Validate a request payload against a RequestSpec. + + Raises ContractError on any validation failure. + """ + for fld in spec.required_fields: + if fld not in payload: + raise ContractError( + code=400, + message=f"Missing required field: {fld}", + details={"missing_field": fld}, + ) + + for fld, expected in spec.field_types.items(): + if fld in payload and payload[fld] is not None: + if not isinstance(payload[fld], expected): + type_name = expected.__name__ if isinstance(expected, type) else str(expected) + raise ContractError( + code=400, + message=f"Invalid type for field {fld}: expected {type_name}", + details={"field": fld, "expected_type": type_name}, + ) + + for fld, pattern in spec.patterns.items(): + if fld in payload and payload[fld] is not None: + if not re.search(pattern, str(payload[fld])): + raise ContractError( + code=400, + message=f"Field {fld} does not match pattern {pattern}", + details={"field": fld, "pattern": pattern}, + ) + + +def make_error_response( + code: int, + message: str, + request_id: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Build a structured error response matching the API contract.""" + body: Dict[str, Any] = {"code": code, "message": message} + if request_id: + body["request_id"] = request_id + if details: + body["details"] = details + return body + + +def validate_login_credentials(payload: Dict[str, Any]) -> None: + """Validate a login request payload.""" + spec = RequestSpec( + required_fields=["email", "password"], + field_types={"email": str, "password": str}, + patterns={"email": r"^[^@\s]+@[^@\s]+\.[^@\s]+$"}, + ) + validate_payload(payload, spec) + + +def validate_order_payload(payload: Dict[str, Any]) -> None: + """Validate an order placement request payload.""" + spec = RequestSpec( + required_fields=["symbol", "side", "type", "quantity"], + field_types={ + "symbol": str, + "side": str, + "type": str, + "quantity": (int, float), + }, + patterns={"symbol": r"^[A-Z0-9]{2,10}/[A-Z0-9]{2,10}$"}, + ) + validate_payload(payload, spec) + if "side" in payload and payload["side"] not in ("buy", "sell"): + raise ContractError( + code=400, + message="Invalid side: must be 'buy' or 'sell'", + details={"field": "side", "allowed": ["buy", "sell"]}, + ) + if "type" in payload and payload["type"] not in ("limit", "market", "stop_limit", "stop_market"): + raise ContractError( + code=400, + message="Invalid order type", + details={"field": "type"}, + ) + + +async def async_validate_login(payload: Dict[str, Any]) -> None: + """Async wrapper around login validation.""" + validate_login_credentials(payload) + + +async def async_validate_order(payload: Dict[str, Any]) -> None: + """Async wrapper around order validation.""" + validate_order_payload(payload) diff --git a/diagnostic/build-209c3616.json b/diagnostic/build-209c3616.json new file mode 100644 index 00000000..c0cfd4b1 --- /dev/null +++ b/diagnostic/build-209c3616.json @@ -0,0 +1,23 @@ +{ + "generated_at": "2026-06-19T08:10:55.776889+00:00", + "commit": "209c3616", + "diagnostic_logd": "diagnostic/build-209c3616.logd", + "diagnostic_logd_error": null, + "chunked": false, + "chunk_size_bytes": null, + "password": "59986907549ab251f913", + "decrypt_command": "encryptly unpack diagnostic/build-209c3616.logd --password 59986907549ab251f913", + "total_modules": 1, + "passed": 1, + "failed": 0, + "modules": [ + { + "name": "market", + "status": "PASS", + "elapsed_seconds": 1.051, + "artifact": "/tmp/repos/jackjin1997-zeroeye/market/market", + "output": "" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-209c3616.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/diagnostic/build-209c3616.logd b/diagnostic/build-209c3616.logd new file mode 100644 index 00000000..d85a6cd6 Binary files /dev/null and b/diagnostic/build-209c3616.logd differ diff --git a/tests/backend_api/test_api_contract.py b/tests/backend_api/test_api_contract.py new file mode 100644 index 00000000..06033765 --- /dev/null +++ b/tests/backend_api/test_api_contract.py @@ -0,0 +1,241 @@ +"""Tests for backend API contract edge cases. + +Covers malformed payloads, async wrappers, and error response shape +validation without requiring network services or optional pytest plugins. +""" + +import asyncio +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "backend")) + +import pytest +from api_contract import ( + ContractError, + RequestSpec, + async_validate_login, + async_validate_order, + make_error_response, + validate_login_credentials, + validate_order_payload, + validate_payload, +) + + +class TestValidatePayloadMissingFields: + def test_missing_single_required_field(self): + spec = RequestSpec(required_fields=["email", "password"]) + with pytest.raises(ContractError) as exc_info: + validate_payload({"email": "a@b.com"}, spec) + assert exc_info.value.code == 400 + assert "password" in exc_info.value.message + assert exc_info.value.details["missing_field"] == "password" + + def test_missing_all_required_fields(self): + spec = RequestSpec(required_fields=["email", "password"]) + with pytest.raises(ContractError) as exc_info: + validate_payload({}, spec) + assert exc_info.value.code == 400 + + def test_optional_field_not_required(self): + spec = RequestSpec( + required_fields=["email"], + optional_fields=["remember_me"], + ) + validate_payload({"email": "a@b.com"}, spec) + + +class TestValidatePayloadTypeErrors: + def test_wrong_type_for_string_field(self): + spec = RequestSpec( + required_fields=["name"], + field_types={"name": str}, + ) + with pytest.raises(ContractError) as exc_info: + validate_payload({"name": 12345}, spec) + assert exc_info.value.code == 400 + assert "Invalid type" in exc_info.value.message + + def test_wrong_type_for_numeric_field(self): + spec = RequestSpec( + required_fields=["quantity"], + field_types={"quantity": (int, float)}, + ) + with pytest.raises(ContractError) as exc_info: + validate_payload({"quantity": "not-a-number"}, spec) + assert exc_info.value.code == 400 + + def test_none_value_skips_type_check(self): + spec = RequestSpec( + required_fields=["optional_thing"], + field_types={"optional_thing": str}, + ) + validate_payload({"optional_thing": None}, spec) + + +class TestValidatePayloadPatternErrors: + def test_pattern_mismatch(self): + spec = RequestSpec( + required_fields=["email"], + patterns={"email": r"^[^@\s]+@[^@\s]+\.[^@\s]+$"}, + ) + with pytest.raises(ContractError) as exc_info: + validate_payload({"email": "not-an-email"}, spec) + assert exc_info.value.code == 400 + assert "pattern" in exc_info.value.details + + def test_pattern_match_success(self): + spec = RequestSpec( + required_fields=["email"], + patterns={"email": r"^[^@\s]+@[^@\s]+\.[^@\s]+$"}, + ) + validate_payload({"email": "user@example.com"}, spec) + + +class TestLoginCredentialValidation: + def test_missing_email(self): + with pytest.raises(ContractError) as exc_info: + validate_login_credentials({"password": "hunter2"}) + assert exc_info.value.code == 400 + assert "email" in exc_info.value.message + + def test_missing_password(self): + with pytest.raises(ContractError) as exc_info: + validate_login_credentials({"email": "a@b.com"}) + assert exc_info.value.code == 400 + assert "password" in exc_info.value.message + + def test_missing_both(self): + with pytest.raises(ContractError): + validate_login_credentials({}) + + def test_invalid_email_format(self): + with pytest.raises(ContractError) as exc_info: + validate_login_credentials({"email": "bad", "password": "x"}) + assert exc_info.value.code == 400 + + def test_valid_credentials(self): + validate_login_credentials({"email": "user@example.com", "password": "hunter2"}) + + +class TestOrderPayloadValidation: + def test_missing_symbol(self): + with pytest.raises(ContractError) as exc_info: + validate_order_payload({"side": "buy", "type": "limit", "quantity": 1}) + assert exc_info.value.code == 400 + assert "symbol" in exc_info.value.message + + def test_invalid_symbol_format(self): + with pytest.raises(ContractError) as exc_info: + validate_order_payload({ + "symbol": "INVALID", + "side": "buy", + "type": "limit", + "quantity": 1, + }) + assert exc_info.value.code == 400 + + def test_invalid_side_value(self): + with pytest.raises(ContractError) as exc_info: + validate_order_payload({ + "symbol": "BTC/USD", + "side": "hold", + "type": "limit", + "quantity": 1, + }) + assert exc_info.value.code == 400 + assert "side" in exc_info.value.message + + def test_invalid_type_value(self): + with pytest.raises(ContractError) as exc_info: + validate_order_payload({ + "symbol": "BTC/USD", + "side": "buy", + "type": "weird_type", + "quantity": 1, + }) + assert exc_info.value.code == 400 + assert "order type" in exc_info.value.message.lower() + + def test_quantity_not_numeric(self): + with pytest.raises(ContractError) as exc_info: + validate_order_payload({ + "symbol": "BTC/USD", + "side": "buy", + "type": "limit", + "quantity": "lots", + }) + assert exc_info.value.code == 400 + + def test_valid_order(self): + validate_order_payload({ + "symbol": "BTC/USD", + "side": "buy", + "type": "limit", + "quantity": 1.5, + }) + + +class TestErrorResponseShape: + def test_error_response_has_code_and_message(self): + resp = make_error_response(401, "Unauthorized") + assert resp["code"] == 401 + assert resp["message"] == "Unauthorized" + assert "request_id" not in resp + + def test_error_response_with_request_id(self): + resp = make_error_response(404, "Not found", request_id="req-abc") + assert resp["request_id"] == "req-abc" + + def test_error_response_with_details(self): + resp = make_error_response(422, "Invalid", details={"field": "email"}) + assert resp["details"]["field"] == "email" + + def test_contract_error_to_response(self): + err = ContractError(400, "Bad request", details={"reason": "missing foo"}) + resp = err.to_response() + assert resp["code"] == 400 + assert resp["message"] == "Bad request" + assert resp["details"]["reason"] == "missing foo" + + +class TestAsyncHelpers: + def _run(self, coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + def test_async_validate_login_valid(self): + self._run(async_validate_login({"email": "a@b.com", "password": "pass"})) + + def test_async_validate_login_missing_field(self): + with pytest.raises(ContractError): + self._run(async_validate_login({"password": "pass"})) + + def test_async_validate_order_valid(self): + self._run(async_validate_order({ + "symbol": "ETH/BTC", + "side": "sell", + "type": "market", + "quantity": 10, + })) + + def test_async_validate_order_bad_side(self): + with pytest.raises(ContractError): + self._run(async_validate_order({ + "symbol": "ETH/BTC", + "side": "hold", + "type": "market", + "quantity": 10, + })) + + def test_async_validate_order_missing_symbol(self): + with pytest.raises(ContractError): + self._run(async_validate_order({ + "side": "buy", + "type": "limit", + "quantity": 1, + }))