Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions backend/api_contract.py
Original file line number Diff line number Diff line change
@@ -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)
23 changes: 23 additions & 0 deletions diagnostic/build-209c3616.json
Original file line number Diff line number Diff line change
@@ -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 <outdir> --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."
}
Binary file added diagnostic/build-209c3616.logd
Binary file not shown.
241 changes: 241 additions & 0 deletions tests/backend_api/test_api_contract.py
Original file line number Diff line number Diff line change
@@ -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,
}))