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
« 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
4"""Provides a self-contained FastAPI application for a CRUD API.
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.
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"""
21from __future__ import annotations
23from collections.abc import AsyncIterator, Awaitable, Callable
24from contextlib import asynccontextmanager
25import logging
26import threading
27from typing import Annotated, Any, Protocol, runtime_checkable
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
46logger = logging.getLogger("bijux_cli.httpapi")
47logging.basicConfig(level=logging.INFO)
50class Problem(BaseModel):
51 """Defines a standard RFC 7807 problem details response.
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 """
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.")
71class ItemIn(BaseModel):
72 """Defines the input model for creating or updating an item.
74 Attributes:
75 name (str): The name of the item.
76 description (str | None): An optional description for the item.
77 """
79 model_config = ConfigDict(
80 extra="forbid",
81 )
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 )
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.
100 Args:
101 v: The input string for the item's name.
103 Returns:
104 The validated and stripped name.
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
115class Item(ItemIn):
116 """Defines the full item model, including its unique identifier.
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 """
124 id: int = Field(..., json_schema_extra={"example": 1})
127class ItemListResponse(BaseModel):
128 """Defines the response model for a paginated list of items.
130 Attributes:
131 items (list[Item]): The list of items on the current page.
132 total (int): The total number of items available.
133 """
135 items: list[Item]
136 total: int
139@runtime_checkable
140class ItemStoreProtocol(Protocol):
141 """Defines the contract for an item storage service."""
143 def list_items(self, limit: int, offset: int) -> tuple[list[Item], int]:
144 """Lists items with pagination."""
145 ...
147 def get(self, item_id: int) -> Item:
148 """Gets an item by its unique ID."""
149 ...
151 def create(self, data: ItemIn) -> Item:
152 """Creates a new item."""
153 ...
155 def update(self, item_id: int, data: ItemIn) -> Item:
156 """Updates an existing item."""
157 ...
159 def delete(self, item_id: int) -> None:
160 """Deletes an item by its unique ID."""
161 ...
163 def reset(self) -> None:
164 """Resets the store to its initial empty state."""
165 ...
167 def prepopulate(self, data: list[dict[str, Any]]) -> None:
168 """Prepopulates the store with a list of items."""
169 ...
171 def find_by_name(self, name: str) -> Item | None:
172 """Returns an item by its name if it exists, otherwise None."""
173 ...
176class InMemoryItemStore(ItemStoreProtocol):
177 """A thread-safe, in-memory implementation of the `ItemStoreProtocol`.
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 """
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
193 def list_items(self, limit: int, offset: int) -> tuple[list[Item], int]:
194 """Lists items with pagination in a thread-safe manner.
196 Args:
197 limit (int): The maximum number of items to return.
198 offset (int): The starting index for the items to return.
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)
207 def get(self, item_id: int) -> Item:
208 """Gets an item by its unique ID.
210 Args:
211 item_id (int): The ID of the item to retrieve.
213 Returns:
214 The requested item.
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
234 def create(self, data: ItemIn) -> Item:
235 """Creates a new item.
237 Args:
238 data (ItemIn): The data for the new item.
240 Returns:
241 The newly created item, including its generated ID.
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
267 def update(self, item_id: int, data: ItemIn) -> Item:
268 """Update an existing item.
270 Args:
271 item_id (int): The unique identifier of the item to update.
272 data (ItemIn): The new values for the item.
274 Returns:
275 The updated item.
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 )
295 old_key = existing.name.strip().lower()
296 new_key = data.name.strip().lower()
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 )
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
318 def delete(self, item_id: int) -> None:
319 """Delete an item by its unique ID.
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)
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")
348 def prepopulate(self, data: list[dict[str, Any]]) -> None:
349 """Prepopulates the store with a list of items.
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))
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
367def get_store() -> ItemStoreProtocol:
368 """A FastAPI dependency to provide the `ItemStoreProtocol` instance."""
369 return store
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)
380def reject_duplicate_query_params(*params: str) -> DependsMarker:
381 """Create a dependency that rejects duplicate query parameters (HTTP 422).
383 Args:
384 *params: Names of query parameters that must not appear more than once.
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.
391 Raises:
392 HTTPException: Emitted at request time if duplicates are detected.
393 """
395 async def _dep(request: Request) -> None:
396 """Raise an HTTPException if specific query parameters are duplicated.
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.
402 Args:
403 request: The incoming FastAPI/Starlette request object.
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 )
424 return DependsMarker(_dep)
427router = APIRouter(prefix="/v1")
430def require_accept_json(request: Request) -> None:
431 """Reject requests that don't accept application/json (HTTP 406).
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 )
451def allow_only(*allowed: str) -> Callable[[Request], Awaitable[None]]:
452 """Create a dependency that rejects unknown query parameters (HTTP 422).
454 Args:
455 *allowed (str): The set of query parameter names that are permitted.
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.
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)
468 async def _dep(request: Request) -> None:
469 """Raise an HTTPException if unknown query parameters are present.
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.
476 Args:
477 request: The incoming FastAPI/Starlette request object.
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 )
497 return _dep
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.
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.
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)
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.
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.
549 Args:
550 item (Item): The item retrieved by the `get_item_or_404` dependency.
552 Returns:
553 Item: The requested item.
554 """
555 return item
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.
583 Args:
584 item (ItemIn): The data for the new item from the request body.
585 store (ItemStoreProtocol): The dependency-injected item store.
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)
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.
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).
624 Returns:
625 The updated item.
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)
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.
656 The target item is resolved by the `get_item_or_404` dependency before this
657 handler runs.
659 Args:
660 item: The item to delete, injected by `get_item_or_404`.
661 store: The item store implementation, injected.
663 Returns:
664 Response: Empty body with **204 No Content** on successful deletion.
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)
673@asynccontextmanager
674async def lifespan(app: FastAPI) -> AsyncIterator[None]:
675 """Manages the application's lifespan events for startup and shutdown.
677 On startup, this context manager resets and prepopulates the in-memory
678 store with demo data. On shutdown, it resets the store again.
680 Args:
681 app (FastAPI): The FastAPI application instance.
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")
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)
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"}
715@app.exception_handler(RequestValidationError)
716async def validation_exception_handler(
717 request: Request, exc: RequestValidationError
718) -> JSONResponse:
719 """A custom exception handler for `RequestValidationError`.
721 This handler intercepts validation errors from FastAPI and formats them
722 into a standard `JSONResponse` with a 422 status code.
724 Args:
725 request (Request): The incoming request.
726 exc (RequestValidationError): The validation exception.
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 )
744@app.exception_handler(HTTPException)
745async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
746 """A custom exception handler for `HTTPException`.
748 This handler intercepts FastAPI's standard HTTP exceptions and ensures they
749 are logged and returned in the standard JSON error format.
751 Args:
752 request (Request): The incoming request.
753 exc (HTTPException): The HTTP exception.
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)