Coverage for  / home / runner / work / bijux-cli / bijux-cli / src / bijux_cli / api / http.py: 99%

186 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-01-26 17:59 +0000

1# SPDX-License-Identifier: Apache-2.0 

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 json 

26import logging 

27import threading 

28from typing import Annotated, Any, Protocol, runtime_checkable 

29 

30from fastapi import ( 

31 APIRouter, 

32 Body, 

33 Depends, 

34 FastAPI, 

35 HTTPException, 

36 Path, 

37 Query, 

38 Request, 

39 Response, 

40 status, 

41) 

42from fastapi.encoders import jsonable_encoder 

43from fastapi.exceptions import RequestValidationError 

44from fastapi.params import Depends as DependsMarker 

45from fastapi.responses import JSONResponse 

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

47 

48logger = logging.getLogger("bijux_cli.api.http") 

49 

50 

51class Problem(BaseModel): 

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

53 

54 Attributes: 

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

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

57 status (int): The HTTP status code. 

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

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

60 """ 

61 

62 type: AnyUrl = Field( 

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

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

65 ) 

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

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

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

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

70 

71 

72class ItemIn(BaseModel): 

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

74 

75 Attributes: 

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

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

78 """ 

79 

80 model_config = ConfigDict( 

81 extra="forbid", 

82 ) 

83 

84 name: str = Field( 

85 ..., 

86 min_length=1, 

87 max_length=100, 

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

89 ) 

90 description: str | None = Field( 

91 None, 

92 max_length=500, 

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

94 ) 

95 

96 @field_validator("name") 

97 @classmethod 

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

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

100 

101 Args: 

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

103 

104 Returns: 

105 The validated and stripped name. 

106 

107 Raises: 

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

109 """ 

110 for ch in v: 

111 codepoint = ord(ch) 

112 if codepoint < 0x20 or 0x7F <= codepoint <= 0x9F: 

113 raise ValueError("name must not contain control characters") 

114 if 0xD800 <= codepoint <= 0xDFFF: 

115 raise ValueError("name must not contain surrogate code points") 

116 stripped_v = v.strip() 

117 if not stripped_v: 

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

119 return stripped_v 

120 

121 

122class Item(ItemIn): 

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

124 

125 Attributes: 

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

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

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

129 """ 

130 

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

132 

133 

134class ItemListResponse(BaseModel): 

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

136 

137 Attributes: 

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

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

140 """ 

141 

142 items: list[Item] 

143 total: int 

144 

145 

146@runtime_checkable 

147class ItemStoreProtocol(Protocol): 

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

149 

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

151 """Lists items with pagination.""" 

152 ... 

153 

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

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

156 ... 

157 

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

159 """Creates a new item.""" 

160 ... 

161 

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

163 """Updates an existing item.""" 

164 ... 

165 

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

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

168 ... 

169 

170 def reset(self) -> None: 

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

172 ... 

173 

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

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

176 ... 

177 

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

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

180 ... 

181 

182 

183class InMemoryItemStore(ItemStoreProtocol): 

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

185 

186 Attributes: 

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

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

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

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

191 """ 

192 

193 def __init__(self) -> None: 

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

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

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

197 self._lock = threading.RLock() 

198 self._next_id = 1 

199 

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

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

202 

203 Args: 

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

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

206 

207 Returns: 

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

209 """ 

210 with self._lock: 

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

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

213 

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

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

216 

217 Args: 

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

219 

220 Returns: 

221 The requested item. 

222 

223 Raises: 

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

225 """ 

226 with self._lock: 

227 item = self._items.get(item_id) 

228 if not item: 

229 raise HTTPException( 

230 status_code=status.HTTP_404_NOT_FOUND, 

231 detail=Problem( 

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

233 title="Not found", 

234 status=status.HTTP_404_NOT_FOUND, 

235 detail="Item not found", 

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

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

238 ) 

239 return item 

240 

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

242 """Creates a new item. 

243 

244 Args: 

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

246 

247 Returns: 

248 The newly created item, including its generated ID. 

249 

250 Raises: 

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

252 """ 

253 with self._lock: 

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

255 if key in self._name_index: 

256 raise HTTPException( 

257 status_code=status.HTTP_409_CONFLICT, 

258 detail=Problem( 

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

260 title="Conflict", 

261 status=status.HTTP_409_CONFLICT, 

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

263 instance="/v1/items", 

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

265 ) 

266 item_id = self._next_id 

267 self._next_id += 1 

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

269 self._items[item_id] = item 

270 self._name_index[key] = item_id 

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

272 return item 

273 

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

275 """Update an existing item. 

276 

277 Args: 

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

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

280 

281 Returns: 

282 The updated item. 

283 

284 Raises: 

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

286 conflicts with another item (HTTP 409). 

287 """ 

288 with self._lock: 

289 existing = self._items.get(item_id) 

290 if existing is None: 

291 raise HTTPException( 

292 status_code=status.HTTP_404_NOT_FOUND, 

293 detail=Problem( 

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

295 title="Not found", 

296 status=status.HTTP_404_NOT_FOUND, 

297 detail="Item not found", 

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

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

300 ) 

301 

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

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

304 

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

306 raise HTTPException( 

307 status_code=status.HTTP_409_CONFLICT, 

308 detail=Problem( 

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

310 title="Conflict", 

311 status=status.HTTP_409_CONFLICT, 

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

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

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

315 ) 

316 

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

318 self._items[item_id] = updated 

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

320 self._name_index.pop(old_key, None) 

321 self._name_index[new_key] = item_id 

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

323 return updated 

324 

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

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

327 

328 Args: 

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

330 """ 

331 with self._lock: 

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

333 if existing is None: 

334 raise HTTPException( 

335 status_code=status.HTTP_404_NOT_FOUND, 

336 detail=Problem( 

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

338 title="Not found", 

339 status=status.HTTP_404_NOT_FOUND, 

340 detail="Item not found", 

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

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

343 ) 

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

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

346 

347 def reset(self) -> None: 

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

349 with self._lock: 

350 self._items.clear() 

351 self._name_index.clear() 

352 self._next_id = 1 

353 logger.info("Store reset") 

354 

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

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

357 

358 Args: 

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

360 the data for a new item. 

361 """ 

362 with self._lock: 

363 for entry in data: 

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

365 

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

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

368 with self._lock: 

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

370 item_id = self._name_index.get(key) 

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

372 

373 

374def get_store() -> ItemStoreProtocol: 

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

376 return store 

377 

378 

379def get_item_or_404( 

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

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

382) -> Item: 

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

384 return store.get(item_id) 

385 

386 

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

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

389 

390 Args: 

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

392 

393 Returns: 

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

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

396 appears more than once. 

397 

398 Raises: 

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

400 """ 

401 

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

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

404 

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

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

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

408 

409 Args: 

410 request: The incoming FastAPI/Starlette request object. 

411 

412 Raises: 

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

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

415 are found more than once. 

416 """ 

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

418 if duplicates: 

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

420 raise HTTPException( 

421 status_code=422, 

422 detail=Problem( 

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

424 title="Validation error", 

425 status=422, 

426 detail=detail, 

427 instance=str(request.url), 

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

429 ) 

430 

431 return DependsMarker(_dep) 

432 

433 

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

435 

436 

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

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

439 

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

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

442 """ 

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

444 if "*/*" in accept or "application/json" in accept: 

445 return 

446 raise HTTPException( 

447 status_code=status.HTTP_406_NOT_ACCEPTABLE, 

448 detail=Problem( 

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

450 title="Not Acceptable", 

451 status=status.HTTP_406_NOT_ACCEPTABLE, 

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

453 instance=str(request.url), 

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

455 ) 

456 

457 

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

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

460 

461 Args: 

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

463 

464 Returns: 

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

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

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

468 

469 Raises: 

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

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

472 """ 

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

474 

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

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

477 

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

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

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

481 variable. 

482 

483 Args: 

484 request: The incoming FastAPI/Starlette request object. 

485 

486 Raises: 

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

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

489 not in the `allowed_set`. 

490 """ 

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

492 if extras: 

493 raise HTTPException( 

494 status_code=422, 

495 detail=Problem( 

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

497 title="Validation error", 

498 status=422, 

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

500 instance=str(request.url), 

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

502 ) 

503 

504 return _dep 

505 

506 

507@router.get( 

508 "/items", 

509 response_model=ItemListResponse, 

510 summary="List items", 

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

512 tags=["Items"], 

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

514 dependencies=[ 

515 Depends(require_accept_json), 

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

517 reject_duplicate_query_params("limit", "offset"), 

518 ], 

519) 

520def list_items( 

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

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

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

524) -> ItemListResponse: 

525 """Retrieves a paginated list of items. 

526 

527 Args: 

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

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

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

531 

532 Returns: 

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

534 """ 

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

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

537 

538 

539@router.get( 

540 "/items/{item_id}", 

541 response_model=Item, 

542 summary="Get item", 

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

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

545 tags=["Items"], 

546 dependencies=[Depends(require_accept_json)], 

547) 

548def get_item( 

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

550) -> Item: 

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

552 

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

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

555 

556 Args: 

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

558 

559 Returns: 

560 Item: The requested item. 

561 """ 

562 return item 

563 

564 

565@router.post( 

566 "/items", 

567 response_model=Item, 

568 status_code=status.HTTP_201_CREATED, 

569 summary="Create item", 

570 description="Create a new item.", 

571 responses={ 

572 200: { 

573 "model": Item, 

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

575 }, 

576 406: {"model": Problem}, 

577 409: {"model": Problem}, 

578 422: {"model": Problem}, 

579 }, 

580 tags=["Items"], 

581 dependencies=[Depends(require_accept_json)], 

582) 

583def create_item( 

584 response: Response, 

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

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

587) -> Item: 

588 """Creates a new item. 

589 

590 Args: 

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

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

593 

594 Returns: 

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

596 """ 

597 existing = store.find_by_name(item.name) 

598 if existing is not None: 

599 response.status_code = status.HTTP_200_OK 

600 return existing 

601 return store.create(item) 

602 

603 

604@router.put( 

605 "/items/{item_id}", 

606 response_model=Item, 

607 summary="Update item", 

608 description="Update an existing item.", 

609 responses={ 

610 406: {"model": Problem}, 

611 404: {"model": Problem}, 

612 409: {"model": Problem}, 

613 422: {"model": Problem}, 

614 }, 

615 tags=["Items"], 

616 dependencies=[Depends(require_accept_json)], 

617) 

618def update_item( 

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

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

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

622) -> Item: 

623 """Update an existing item. 

624 

625 Args: 

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

627 injected by ``get_item_or_404``. 

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

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

630 

631 Returns: 

632 The updated item. 

633 

634 Raises: 

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

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

637 or the store layer. 

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

639 by the global validation exception handler. 

640 """ 

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

642 

643 

644@router.delete( 

645 "/items/{item_id}", 

646 status_code=status.HTTP_204_NO_CONTENT, 

647 summary="Delete item", 

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

649 responses={ 

650 406: {"model": Problem}, 

651 404: {"model": Problem}, 

652 422: {"model": Problem}, 

653 }, 

654 tags=["Items"], 

655 dependencies=[Depends(require_accept_json)], 

656) 

657def delete_item( 

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

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

660) -> Response: 

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

662 

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

664 handler runs. 

665 

666 Args: 

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

668 store: The item store implementation, injected. 

669 

670 Returns: 

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

672 

673 Raises: 

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

675 """ 

676 store.delete(item.id) 

677 return Response(status_code=status.HTTP_204_NO_CONTENT) 

678 

679 

680@asynccontextmanager 

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

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

683 

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

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

686 

687 Args: 

688 app (FastAPI): The FastAPI application instance. 

689 

690 Yields: 

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

692 """ 

693 store.reset() 

694 store.prepopulate( 

695 [ 

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

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

698 ] 

699 ) 

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

701 yield 

702 store.reset() 

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

704 

705 

706store = InMemoryItemStore() 

707app = FastAPI( 

708 title="Bijux CLI API", 

709 version="1.0.0", 

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

711 lifespan=lifespan, 

712) 

713app.include_router(router) 

714 

715 

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

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

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

719 return {"status": "ok"} 

720 

721 

722@app.exception_handler(RequestValidationError) 

723async def validation_exception_handler( 

724 request: Request, exc: RequestValidationError 

725) -> JSONResponse: 

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

727 

728 This handler intercepts validation errors from FastAPI and formats them 

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

730 

731 Args: 

732 request (Request): The incoming request. 

733 exc (RequestValidationError): The validation exception. 

734 

735 Returns: 

736 JSONResponse: A JSON response detailing the validation error. 

737 """ 

738 errors = jsonable_encoder(exc.errors()) 

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

740 return JSONResponse( 

741 status_code=422, 

742 content={ 

743 "type": "https://bijux-cli.dev/docs/errors/validation-error", 

744 "title": "Validation error", 

745 "status": 422, 

746 "detail": json.dumps(errors), 

747 "instance": str(request.url), 

748 }, 

749 ) 

750 

751 

752@app.exception_handler(HTTPException) 

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

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

755 

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

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

758 

759 Args: 

760 request (Request): The incoming request. 

761 exc (HTTPException): The HTTP exception. 

762 

763 Returns: 

764 JSONResponse: A JSON response detailing the HTTP error. 

765 """ 

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

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