Coverage for /home/runner/work/bijux-cli/bijux-cli/src/bijux_cli/httpapi.py: 92%

178 statements  

« prev     ^ index     » next       coverage.py v7.10.4, created at 2025-08-19 23:36 +0000

1# SPDX-License-Identifier: MIT 

2# Copyright © 2025 Bijan Mousavi 

3 

4"""Provides a self-contained FastAPI application for a CRUD API. 

5 

6This module defines a complete HTTP API for managing "Item" resources using 

7the FastAPI framework. It includes all necessary components for a functional 

8web service. 

9 

10Services: 

11 * **Pydantic Models:** `ItemIn`, `Item`, and response models for data 

12 validation and serialization. 

13 * **Storage Layer:** A formal `ItemStoreProtocol` and a concrete, 

14 thread-safe `InMemoryItemStore` implementation. 

15 * **API Endpoints:** A FastAPI `APIRouter` with path operations for all 

16 CRUD (Create, Read, Update, Delete) actions. 

17 * **Application Lifecycle:** A `lifespan` manager to prepopulate and 

18 clear the data store on startup and shutdown. 

19""" 

20 

21from __future__ import annotations 

22 

23from collections.abc import AsyncIterator, Awaitable, Callable 

24from contextlib import asynccontextmanager 

25import logging 

26import threading 

27from typing import Annotated, Any, Protocol, runtime_checkable 

28 

29from fastapi import ( 

30 APIRouter, 

31 Body, 

32 Depends, 

33 FastAPI, 

34 HTTPException, 

35 Path, 

36 Query, 

37 Request, 

38 Response, 

39 status, 

40) 

41from fastapi.exceptions import RequestValidationError 

42from fastapi.params import Depends as DependsMarker 

43from fastapi.responses import JSONResponse 

44from pydantic import AnyUrl, BaseModel, ConfigDict, Field, field_validator 

45 

46logger = logging.getLogger("bijux_cli.httpapi") 

47logging.basicConfig(level=logging.INFO) 

48 

49 

50class Problem(BaseModel): 

51 """Defines a standard RFC 7807 problem details response. 

52 

53 Attributes: 

54 type (AnyUrl): A URI reference that identifies the problem type. 

55 title (str): A short, human-readable summary of the problem type. 

56 status (int): The HTTP status code. 

57 detail (str): A human-readable explanation specific to this occurrence. 

58 instance (str): A URI reference that identifies the specific occurrence. 

59 """ 

60 

61 type: AnyUrl = Field( 

62 default=AnyUrl("about:blank"), 

63 description="A URI reference that identifies the problem type.", 

64 ) 

65 title: str = Field(..., description="A short, human-readable summary.") 

66 status: int = Field(..., description="The HTTP status code.") 

67 detail: str = Field(..., description="A human-readable explanation.") 

68 instance: str = Field(..., description="A URI reference for this occurrence.") 

69 

70 

71class ItemIn(BaseModel): 

72 """Defines the input model for creating or updating an item. 

73 

74 Attributes: 

75 name (str): The name of the item. 

76 description (str | None): An optional description for the item. 

77 """ 

78 

79 model_config = ConfigDict( 

80 extra="forbid", 

81 ) 

82 

83 name: str = Field( 

84 ..., 

85 min_length=1, 

86 max_length=100, 

87 json_schema_extra={"example": "Sample"}, 

88 ) 

89 description: str | None = Field( 

90 None, 

91 max_length=500, 

92 json_schema_extra={"example": "Details about this item"}, 

93 ) 

94 

95 @field_validator("name") 

96 @classmethod 

97 def validate_and_normalize_name(cls: type[ItemIn], v: str) -> str: # noqa: N805 

98 """Strips whitespace and ensures the name is not empty. 

99 

100 Args: 

101 v: The input string for the item's name. 

102 

103 Returns: 

104 The validated and stripped name. 

105 

106 Raises: 

107 ValueError: If the name is empty or contains only whitespace. 

108 """ 

109 stripped_v = v.strip() 

110 if not stripped_v: 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true

111 raise ValueError("name must not be empty or contain only whitespace") 

112 return stripped_v 

113 

114 

115class Item(ItemIn): 

116 """Defines the full item model, including its unique identifier. 

117 

118 Attributes: 

119 id (int): The unique identifier for the item. 

120 name (str): The name of the item. 

121 description (str | None): An optional description for the item. 

122 """ 

123 

124 id: int = Field(..., json_schema_extra={"example": 1}) 

125 

126 

127class ItemListResponse(BaseModel): 

128 """Defines the response model for a paginated list of items. 

129 

130 Attributes: 

131 items (list[Item]): The list of items on the current page. 

132 total (int): The total number of items available. 

133 """ 

134 

135 items: list[Item] 

136 total: int 

137 

138 

139@runtime_checkable 

140class ItemStoreProtocol(Protocol): 

141 """Defines the contract for an item storage service.""" 

142 

143 def list_items(self, limit: int, offset: int) -> tuple[list[Item], int]: 

144 """Lists items with pagination.""" 

145 ... 

146 

147 def get(self, item_id: int) -> Item: 

148 """Gets an item by its unique ID.""" 

149 ... 

150 

151 def create(self, data: ItemIn) -> Item: 

152 """Creates a new item.""" 

153 ... 

154 

155 def update(self, item_id: int, data: ItemIn) -> Item: 

156 """Updates an existing item.""" 

157 ... 

158 

159 def delete(self, item_id: int) -> None: 

160 """Deletes an item by its unique ID.""" 

161 ... 

162 

163 def reset(self) -> None: 

164 """Resets the store to its initial empty state.""" 

165 ... 

166 

167 def prepopulate(self, data: list[dict[str, Any]]) -> None: 

168 """Prepopulates the store with a list of items.""" 

169 ... 

170 

171 def find_by_name(self, name: str) -> Item | None: 

172 """Returns an item by its name if it exists, otherwise None.""" 

173 ... 

174 

175 

176class InMemoryItemStore(ItemStoreProtocol): 

177 """A thread-safe, in-memory implementation of the `ItemStoreProtocol`. 

178 

179 Attributes: 

180 _items (dict): The main dictionary storing items by their ID. 

181 _name_index (dict): An index to enforce unique item names. 

182 _lock (threading.RLock): A lock to ensure thread-safe operations. 

183 _next_id (int): A counter for generating new item IDs. 

184 """ 

185 

186 def __init__(self) -> None: 

187 """Initializes the in-memory item store.""" 

188 self._items: dict[int, Item] = {} 

189 self._name_index: dict[str, int] = {} 

190 self._lock = threading.RLock() 

191 self._next_id = 1 

192 

193 def list_items(self, limit: int, offset: int) -> tuple[list[Item], int]: 

194 """Lists items with pagination in a thread-safe manner. 

195 

196 Args: 

197 limit (int): The maximum number of items to return. 

198 offset (int): The starting index for the items to return. 

199 

200 Returns: 

201 A tuple containing the list of items and the total number of items. 

202 """ 

203 with self._lock: 

204 items = list(self._items.values()) 

205 return items[offset : offset + limit], len(items) 

206 

207 def get(self, item_id: int) -> Item: 

208 """Gets an item by its unique ID. 

209 

210 Args: 

211 item_id (int): The ID of the item to retrieve. 

212 

213 Returns: 

214 The requested item. 

215 

216 Raises: 

217 HTTPException: With status 404 if the item is not found. 

218 """ 

219 with self._lock: 

220 item = self._items.get(item_id) 

221 if not item: 

222 raise HTTPException( 

223 status_code=status.HTTP_404_NOT_FOUND, 

224 detail=Problem( 

225 type=AnyUrl("https://bijux-cli.dev/docs/errors/not-found"), 

226 title="Not found", 

227 status=status.HTTP_404_NOT_FOUND, 

228 detail="Item not found", 

229 instance=f"/v1/items/{item_id}", 

230 ).model_dump(mode="json"), 

231 ) 

232 return item 

233 

234 def create(self, data: ItemIn) -> Item: 

235 """Creates a new item. 

236 

237 Args: 

238 data (ItemIn): The data for the new item. 

239 

240 Returns: 

241 The newly created item, including its generated ID. 

242 

243 Raises: 

244 HTTPException: With status 409 if an item with the same name exists. 

245 """ 

246 with self._lock: 

247 key = data.name.strip().lower() 

248 if key in self._name_index: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true

249 raise HTTPException( 

250 status_code=status.HTTP_409_CONFLICT, 

251 detail=Problem( 

252 type=AnyUrl("https://bijux-cli.dev/docs/errors/conflict"), 

253 title="Conflict", 

254 status=status.HTTP_409_CONFLICT, 

255 detail="Item with this name already exists", 

256 instance="/v1/items", 

257 ).model_dump(mode="json"), 

258 ) 

259 item_id = self._next_id 

260 self._next_id += 1 

261 item = Item(id=item_id, name=data.name, description=data.description) 

262 self._items[item_id] = item 

263 self._name_index[key] = item_id 

264 logger.info("Created item: %s", item) 

265 return item 

266 

267 def update(self, item_id: int, data: ItemIn) -> Item: 

268 """Update an existing item. 

269 

270 Args: 

271 item_id (int): The unique identifier of the item to update. 

272 data (ItemIn): The new values for the item. 

273 

274 Returns: 

275 The updated item. 

276 

277 Raises: 

278 HTTPException: If the item does not exist (HTTP 404) or if the new name 

279 conflicts with another item (HTTP 409). 

280 """ 

281 with self._lock: 

282 existing = self._items.get(item_id) 

283 if existing is None: 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true

284 raise HTTPException( 

285 status_code=status.HTTP_404_NOT_FOUND, 

286 detail=Problem( 

287 type=AnyUrl("https://bijux-cli.dev/docs/errors/not-found"), 

288 title="Not found", 

289 status=status.HTTP_404_NOT_FOUND, 

290 detail="Item not found", 

291 instance=f"/v1/items/{item_id}", 

292 ).model_dump(mode="json"), 

293 ) 

294 

295 old_key = existing.name.strip().lower() 

296 new_key = data.name.strip().lower() 

297 

298 if new_key != old_key and new_key in self._name_index: 

299 raise HTTPException( 

300 status_code=status.HTTP_409_CONFLICT, 

301 detail=Problem( 

302 type=AnyUrl("https://bijux-cli.dev/docs/errors/conflict"), 

303 title="Conflict", 

304 status=status.HTTP_409_CONFLICT, 

305 detail="Item with this name already exists", 

306 instance=f"/v1/items/{item_id}", 

307 ).model_dump(mode="json"), 

308 ) 

309 

310 updated = Item(id=item_id, name=data.name, description=data.description) 

311 self._items[item_id] = updated 

312 if new_key != old_key: 312 ↛ 315line 312 didn't jump to line 315 because the condition on line 312 was always true

313 self._name_index.pop(old_key, None) 

314 self._name_index[new_key] = item_id 

315 logger.info("Updated item id=%s", item_id) 

316 return updated 

317 

318 def delete(self, item_id: int) -> None: 

319 """Delete an item by its unique ID. 

320 

321 Args: 

322 item_id: The unique ID of the item to delete. 

323 """ 

324 with self._lock: 

325 existing = self._items.pop(item_id, None) 

326 if existing is None: 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true

327 raise HTTPException( 

328 status_code=status.HTTP_404_NOT_FOUND, 

329 detail=Problem( 

330 type=AnyUrl("https://bijux-cli.dev/docs/errors/not-found"), 

331 title="Not found", 

332 status=status.HTTP_404_NOT_FOUND, 

333 detail="Item not found", 

334 instance=f"/v1/items/{item_id}", 

335 ).model_dump(mode="json"), 

336 ) 

337 self._name_index.pop(existing.name.strip().lower(), None) 

338 logger.info("Deleted item id=%s", item_id) 

339 

340 def reset(self) -> None: 

341 """Resets the store to its initial empty state.""" 

342 with self._lock: 

343 self._items.clear() 

344 self._name_index.clear() 

345 self._next_id = 1 

346 logger.info("Store reset") 

347 

348 def prepopulate(self, data: list[dict[str, Any]]) -> None: 

349 """Prepopulates the store with a list of items. 

350 

351 Args: 

352 data: A list of dictionaries, where each dictionary contains 

353 the data for a new item. 

354 """ 

355 with self._lock: 

356 for entry in data: 

357 self.create(ItemIn(**entry)) 

358 

359 def find_by_name(self, name: str) -> Item | None: 

360 """Lookup an item by its name (case-insensitive, trimmed).""" 

361 with self._lock: 

362 key = name.strip().lower() 

363 item_id = self._name_index.get(key) 

364 return self._items.get(item_id) if item_id is not None else None 

365 

366 

367def get_store() -> ItemStoreProtocol: 

368 """A FastAPI dependency to provide the `ItemStoreProtocol` instance.""" 

369 return store 

370 

371 

372def get_item_or_404( 

373 item_id: int = Path(..., ge=1), 

374 store: ItemStoreProtocol = Depends(get_store), # noqa: B008 

375) -> Item: 

376 """A dependency that retrieves an item by ID or raises a 404.""" 

377 return store.get(item_id) 

378 

379 

380def reject_duplicate_query_params(*params: str) -> DependsMarker: 

381 """Create a dependency that rejects duplicate query parameters (HTTP 422). 

382 

383 Args: 

384 *params: Names of query parameters that must not appear more than once. 

385 

386 Returns: 

387 fastapi.params.Depends: A dependency marker that, when executed at 

388 request time, raises ``HTTPException`` (422) if any listed parameter 

389 appears more than once. 

390 

391 Raises: 

392 HTTPException: Emitted at request time if duplicates are detected. 

393 """ 

394 

395 async def _dep(request: Request) -> None: 

396 """Raise an HTTPException if specific query parameters are duplicated. 

397 

398 This function is designed to be used as a FastAPI dependency. It checks an 

399 iterable of parameter names (assumed to be in the parent scope's `params` 

400 variable) to ensure they are not repeated in the request's query string. 

401 

402 Args: 

403 request: The incoming FastAPI/Starlette request object. 

404 

405 Raises: 

406 HTTPException: An exception with a 422 status code and a 

407 problem+json body if any of the specified query parameters 

408 are found more than once. 

409 """ 

410 duplicates = [p for p in params if len(request.query_params.getlist(p)) > 1] 

411 if duplicates: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true

412 detail = f"Duplicate query params found: {', '.join(sorted(duplicates))}" 

413 raise HTTPException( 

414 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

415 detail=Problem( 

416 type=AnyUrl("https://bijux-cli.dev/docs/errors/validation-error"), 

417 title="Validation error", 

418 status=status.HTTP_422_UNPROCESSABLE_ENTITY, 

419 detail=detail, 

420 instance=str(request.url), 

421 ).model_dump(mode="json"), 

422 ) 

423 

424 return DependsMarker(_dep) 

425 

426 

427router = APIRouter(prefix="/v1") 

428 

429 

430def require_accept_json(request: Request) -> None: 

431 """Reject requests that don't accept application/json (HTTP 406). 

432 

433 Schemathesis' negative-data checks may send unsupported Accept headers. 

434 If the client doesn't accept JSON (and not */*), respond with 406. 

435 """ 

436 accept = request.headers.get("accept", "*/*").lower() 

437 if "*/*" in accept or "application/json" in accept: 437 ↛ 439line 437 didn't jump to line 439 because the condition on line 437 was always true

438 return 

439 raise HTTPException( 

440 status_code=status.HTTP_406_NOT_ACCEPTABLE, 

441 detail=Problem( 

442 type=AnyUrl("https://bijux-cli.dev/docs/errors/not-acceptable"), 

443 title="Not Acceptable", 

444 status=status.HTTP_406_NOT_ACCEPTABLE, 

445 detail="Set 'Accept: application/json' for this endpoint", 

446 instance=str(request.url), 

447 ).model_dump(mode="json"), 

448 ) 

449 

450 

451def allow_only(*allowed: str) -> Callable[[Request], Awaitable[None]]: 

452 """Create a dependency that rejects unknown query parameters (HTTP 422). 

453 

454 Args: 

455 *allowed (str): The set of query parameter names that are permitted. 

456 

457 Returns: 

458 Callable[[Request], Awaitable[None]]: An async dependency suitable for 

459 FastAPI's ``dependencies=[...]``. It raises an ``HTTPException`` with 

460 status 422 if the request includes parameters outside the allowlist. 

461 

462 Raises: 

463 HTTPException: Emitted by the returned dependency at request time when 

464 unknown query parameters are present (422 Unprocessable Entity). 

465 """ 

466 allowed_set: set[str] = set(allowed) 

467 

468 async def _dep(request: Request) -> None: 

469 """Raise an HTTPException if unknown query parameters are present. 

470 

471 This function is designed to be used as a FastAPI dependency. It validates 

472 that the request's query string contains only parameters from a pre-defined 

473 set of allowed names, assumed to be in the parent scope's `allowed_set` 

474 variable. 

475 

476 Args: 

477 request: The incoming FastAPI/Starlette request object. 

478 

479 Raises: 

480 HTTPException: An exception with a 422 status code and a 

481 problem+json body if any query parameters are found that are 

482 not in the `allowed_set`. 

483 """ 

484 extras = set(request.query_params.keys()) - allowed_set 

485 if extras: 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true

486 raise HTTPException( 

487 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

488 detail=Problem( 

489 type=AnyUrl("https://bijux-cli.dev/docs/errors/validation-error"), 

490 title="Validation error", 

491 status=status.HTTP_422_UNPROCESSABLE_ENTITY, 

492 detail=f"Unknown query params: {', '.join(sorted(extras))}", 

493 instance=str(request.url), 

494 ).model_dump(mode="json"), 

495 ) 

496 

497 return _dep 

498 

499 

500@router.get( 

501 "/items", 

502 response_model=ItemListResponse, 

503 summary="List items", 

504 description="List all items with pagination.", 

505 tags=["Items"], 

506 responses={406: {"model": Problem}, 422: {"model": Problem}}, 

507 dependencies=[ 

508 Depends(require_accept_json), 

509 Depends(allow_only("limit", "offset")), 

510 reject_duplicate_query_params("limit", "offset"), 

511 ], 

512) 

513def list_items( 

514 limit: int = Query(10, ge=1, le=100), 

515 offset: int = Query(0, ge=0), 

516 store: ItemStoreProtocol = Depends(get_store), # noqa: B008 

517) -> ItemListResponse: 

518 """Retrieves a paginated list of items. 

519 

520 Args: 

521 limit (int): The maximum number of items per page. 

522 offset (int): The starting offset for the item list. 

523 store (ItemStoreProtocol): The dependency-injected item store. 

524 

525 Returns: 

526 ItemListResponse: An object containing the list of items and total count. 

527 """ 

528 items, total = store.list_items(limit, offset) 

529 return ItemListResponse(items=items, total=total) 

530 

531 

532@router.get( 

533 "/items/{item_id}", 

534 response_model=Item, 

535 summary="Get item", 

536 description="Get a single item by its ID.", 

537 responses={404: {"model": Problem}, 406: {"model": Problem}}, 

538 tags=["Items"], 

539 dependencies=[Depends(require_accept_json)], 

540) 

541def get_item( 

542 item: Item = Depends(get_item_or_404), # noqa: B008 

543) -> Item: 

544 """Retrieves a single item by its ID. 

545 

546 This endpoint uses a dependency (`get_item_or_404`) to fetch the item, 

547 ensuring that a 404 response is returned if the item does not exist. 

548 

549 Args: 

550 item (Item): The item retrieved by the `get_item_or_404` dependency. 

551 

552 Returns: 

553 Item: The requested item. 

554 """ 

555 return item 

556 

557 

558@router.post( 

559 "/items", 

560 response_model=Item, 

561 status_code=status.HTTP_201_CREATED, 

562 summary="Create item", 

563 description="Create a new item.", 

564 responses={ 

565 200: { 

566 "model": Item, 

567 "description": "Item already exists; existing resource returned", 

568 }, 

569 406: {"model": Problem}, 

570 409: {"model": Problem}, 

571 422: {"model": Problem}, 

572 }, 

573 tags=["Items"], 

574 dependencies=[Depends(require_accept_json)], 

575) 

576def create_item( 

577 response: Response, 

578 item: ItemIn = Body(...), # noqa: B008 

579 store: ItemStoreProtocol = Depends(get_store), # noqa: B008 

580) -> Item: 

581 """Creates a new item. 

582 

583 Args: 

584 item (ItemIn): The data for the new item from the request body. 

585 store (ItemStoreProtocol): The dependency-injected item store. 

586 

587 Returns: 

588 Item: The newly created item, including its server-generated ID. 

589 """ 

590 existing = store.find_by_name(item.name) 

591 if existing is not None: 

592 response.status_code = status.HTTP_200_OK 

593 return existing 

594 return store.create(item) 

595 

596 

597@router.put( 

598 "/items/{item_id}", 

599 response_model=Item, 

600 summary="Update item", 

601 description="Update an existing item.", 

602 responses={ 

603 406: {"model": Problem}, 

604 404: {"model": Problem}, 

605 409: {"model": Problem}, 

606 422: {"model": Problem}, 

607 }, 

608 tags=["Items"], 

609 dependencies=[Depends(require_accept_json)], 

610) 

611def update_item( 

612 item: Annotated[Item, Depends(get_item_or_404)], 

613 update_data: Annotated[ItemIn, Body(...)], 

614 store: Annotated[ItemStoreProtocol, Depends(get_store)], 

615) -> Item: 

616 """Update an existing item. 

617 

618 Args: 

619 item (Item): The current item resolved from the path parameter, 

620 injected by ``get_item_or_404``. 

621 update_data (ItemIn): The new values for the item (request body). 

622 store (ItemStoreProtocol): The item store implementation (injected). 

623 

624 Returns: 

625 The updated item. 

626 

627 Raises: 

628 HTTPException: If the item does not exist (404) or if the new name 

629 conflicts with another item (409). These are raised by the dependency 

630 or the store layer. 

631 RequestValidationError: If the path/body validation fails (422). Handled 

632 by the global validation exception handler. 

633 """ 

634 return store.update(item.id, update_data) 

635 

636 

637@router.delete( 

638 "/items/{item_id}", 

639 status_code=status.HTTP_204_NO_CONTENT, 

640 summary="Delete item", 

641 description="Delete an item by its ID.", 

642 responses={ 

643 406: {"model": Problem}, 

644 404: {"model": Problem}, 

645 422: {"model": Problem}, 

646 }, 

647 tags=["Items"], 

648 dependencies=[Depends(require_accept_json)], 

649) 

650def delete_item( 

651 item: Annotated[Item, Depends(get_item_or_404)], 

652 store: Annotated[ItemStoreProtocol, Depends(get_store)], 

653) -> Response: 

654 """Delete an item by its unique ID. 

655 

656 The target item is resolved by the `get_item_or_404` dependency before this 

657 handler runs. 

658 

659 Args: 

660 item: The item to delete, injected by `get_item_or_404`. 

661 store: The item store implementation, injected. 

662 

663 Returns: 

664 Response: Empty body with **204 No Content** on successful deletion. 

665 

666 Raises: 

667 HTTPException: 404 if the item does not exist (raised by the dependency). 

668 """ 

669 store.delete(item.id) 

670 return Response(status_code=status.HTTP_204_NO_CONTENT) 

671 

672 

673@asynccontextmanager 

674async def lifespan(app: FastAPI) -> AsyncIterator[None]: 

675 """Manages the application's lifespan events for startup and shutdown. 

676 

677 On startup, this context manager resets and prepopulates the in-memory 

678 store with demo data. On shutdown, it resets the store again. 

679 

680 Args: 

681 app (FastAPI): The FastAPI application instance. 

682 

683 Yields: 

684 None: Yields control to the application while it is running. 

685 """ 

686 store.reset() 

687 store.prepopulate( 

688 [ 

689 {"name": "Item One", "description": "Description one"}, 

690 {"name": "Item Two", "description": "Description two"}, 

691 ] 

692 ) 

693 logger.info("Prepopulated store with demo items") 

694 yield 

695 store.reset() 

696 logger.info("Store reset on shutdown") 

697 

698 

699store = InMemoryItemStore() 

700app = FastAPI( 

701 title="Bijux CLI API", 

702 version="1.0.0", 

703 description="High-quality demo API for educational/reference purposes.", 

704 lifespan=lifespan, 

705) 

706app.include_router(router) 

707 

708 

709@app.get("/health", summary="Health check", tags=["Health"]) 

710async def health() -> dict[str, str]: 

711 """Lightweight readiness probe used by Makefile `api-test`.""" 

712 return {"status": "ok"} 

713 

714 

715@app.exception_handler(RequestValidationError) 

716async def validation_exception_handler( 

717 request: Request, exc: RequestValidationError 

718) -> JSONResponse: 

719 """A custom exception handler for `RequestValidationError`. 

720 

721 This handler intercepts validation errors from FastAPI and formats them 

722 into a standard `JSONResponse` with a 422 status code. 

723 

724 Args: 

725 request (Request): The incoming request. 

726 exc (RequestValidationError): The validation exception. 

727 

728 Returns: 

729 JSONResponse: A JSON response detailing the validation error. 

730 """ 

731 logger.warning("Validation error: %s", exc.errors()) 

732 return JSONResponse( 

733 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

734 content=Problem( 

735 type=AnyUrl("https://bijux-cli.dev/docs/errors/validation-error"), 

736 title="Validation error", 

737 status=status.HTTP_422_UNPROCESSABLE_ENTITY, 

738 detail=str(exc), 

739 instance=str(request.url), 

740 ).model_dump(mode="json"), 

741 ) 

742 

743 

744@app.exception_handler(HTTPException) 

745async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: 

746 """A custom exception handler for `HTTPException`. 

747 

748 This handler intercepts FastAPI's standard HTTP exceptions and ensures they 

749 are logged and returned in the standard JSON error format. 

750 

751 Args: 

752 request (Request): The incoming request. 

753 exc (HTTPException): The HTTP exception. 

754 

755 Returns: 

756 JSONResponse: A JSON response detailing the HTTP error. 

757 """ 

758 logger.warning("HTTP error: %s %s", exc.status_code, exc.detail) 

759 return JSONResponse(status_code=exc.status_code, content=exc.detail)