Coverage for src/lexigram/web/routing/controller.py: 46%
67 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Web controllers and routing utilities."""
3from __future__ import annotations
5from collections.abc import Callable
6from typing import Any, Generic, TypeVar
8from lexigram.contracts.exceptions.domain import DomainError
9from lexigram.contracts.web.protocols import CRUDServiceProtocol
10from lexigram.logging import get_logger
11from lexigram.result import Ok, Result
12from lexigram.web.exceptions import (
13 NotFoundError,
14)
15from lexigram.web.routing.controllers import Controller
16from lexigram.web.routing.decorators import (
17 delete,
18 get,
19 head,
20 options,
21 patch,
22 post,
23 put,
24)
25from lexigram.web.transport.responses import json_response
27T = TypeVar("T")
28R = TypeVar("R", bound=Callable[..., Any])
31class GenericController(Controller, Generic[T]):
32 """Generic controller with common CRUD patterns for any service.
34 Returns ``Result[T, DomainError]`` from all action methods. The
35 ``ResponseSerializer`` in the request pipeline automatically maps:
37 - ``Ok(value)`` → 200 JSON response (201 for ``create_item``).
38 - ``Err(error)`` → HTTP error via ``ResultResponseMapper``.
40 Use ``@error_status`` on your domain error classes to control the
41 HTTP status code they map to::
43 from lexigram.web import error_status
45 @error_status(404)
46 class ItemNotFound(DomainError): ...
47 """
49 def __init__(
50 self,
51 service: CRUDServiceProtocol[T],
52 resource_name: str | None = None,
53 ) -> None:
54 super().__init__()
55 self.service = service
56 self._resource_name = resource_name or self._get_default_resource_name()
57 self._logger = get_logger(self.__class__.__name__)
59 def _get_default_resource_name(self) -> str:
60 """Get default resource name from class name."""
61 return (
62 self.__class__.__name__.lower().replace("controller", "").replace("v1", "")
63 )
65 @property
66 def resource_name(self) -> str:
67 """Get the resource name for permissions and error messages."""
68 return self._resource_name
70 @property
71 def logger(self) -> Any:
72 """Get the logger for this controller."""
73 return self._logger
75 @get("/")
76 async def list_items(
77 self,
78 limit: int = 20,
79 offset: int = 0,
80 **filters: Any,
81 ) -> Result[dict[str, Any], DomainError]:
82 """List items with pagination.
84 Returns a ``Result`` whose ``Ok`` payload is a dict with keys
85 ``items``, ``limit``, ``offset``, and ``total``.
86 """
87 result = await self.service.list_items(limit=limit, offset=offset, **filters)
88 if not result.is_ok():
89 return result # type: ignore[return-value]
90 items = result.unwrap()
91 return Ok(
92 {
93 "items": items,
94 "limit": limit,
95 "offset": offset,
96 "total": len(items),
97 }
98 )
100 @get("/{item_id}")
101 async def get_item(self, item_id: str) -> Result[T, DomainError]:
102 """Get single item by ID."""
103 result = await self.service.get(item_id)
104 if not result.is_ok():
105 return result # type: ignore[return-value]
106 item = result.unwrap()
107 if not item:
108 raise NotFoundError(f"{self.resource_name.title()} not found")
109 return Ok(item)
111 @post("/")
112 async def create_item(self, data: dict[str, Any]) -> Any:
113 """Create new item (returns 201 on success)."""
114 result = await self.service.create(data)
115 if not result.is_ok():
116 return result
117 return json_response(result.unwrap(), status_code=201)
119 @put("/{item_id}")
120 async def update_item(
121 self, item_id: str, data: dict[str, Any]
122 ) -> Result[T, DomainError]:
123 """Update existing item."""
124 result = await self.service.update(item_id, data)
125 if not result.is_ok():
126 return result # type: ignore[return-value]
127 item = result.unwrap()
128 if not item:
129 raise NotFoundError(f"{self.resource_name.title()} not found")
130 return Ok(item)
132 @delete("/{item_id}")
133 async def delete_item(self, item_id: str) -> Any:
134 """Delete item (returns 204 on success)."""
135 result = await self.service.delete(item_id)
136 if not result.is_ok():
137 return result
138 if not result.unwrap():
139 raise NotFoundError(f"{self.resource_name.title()} not found")
140 return json_response({}, status_code=204)
143__all__ = [
144 "CRUDServiceProtocol",
145 "Controller",
146 "GenericController",
147 "T",
148 "delete",
149 "get",
150 "head",
151 "options",
152 "patch",
153 "post",
154 "put",
155]