Coverage for src/extratools_api/crudl.py: 0%
79 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-22 20:59 -0700
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-22 20:59 -0700
1import json
2from collections.abc import Awaitable, Callable, Iterable, Mapping, MutableMapping
3from http import HTTPStatus
4from typing import Annotated, Any, cast
6from extratools_core.crudl import CRUDLWrapper, RLWrapper
7from extratools_core.json import JsonDict
8from extratools_core.str import wildcard_match
9from extratools_core.typing import SearchableMapping
10from fastapi import Body, FastAPI, HTTPException
11from pydantic import BaseModel, ConfigDict, ValidationError
14def add_crudl_endpoints[KT: str, VT: JsonDict | BaseModel](
15 app: FastAPI,
16 path_prefix: str,
17 *,
18 create_func: Callable[[KT, JsonDict], Awaitable[VT | None]] | None = None,
19 read_func: Callable[[KT], Awaitable[VT]] | None = None,
20 update_func: Callable[[KT, JsonDict], Awaitable[VT | None]] | None = None,
21 delete_func: Callable[[KT], Awaitable[VT | None]] | None = None,
22 list_func: Callable[[JsonDict | None], Awaitable[Iterable[tuple[KT, Any]]]] | None = None,
23) -> None:
24 path_prefix = path_prefix.rstrip("/")
26 if create_func:
27 @app.put(path_prefix + "/{identifier}")
28 async def create_endpoint(
29 identifier: KT,
30 put_body: Annotated[JsonDict, Body()],
31 ) -> VT | None:
32 return await create_func(
33 identifier,
34 put_body,
35 )
37 if read_func:
38 @app.get(path_prefix + "/{identifier}")
39 async def read_endpoint(identifier: KT) -> VT:
40 return await read_func(
41 identifier,
42 )
44 if update_func:
45 @app.patch(path_prefix + "/{identifier}")
46 async def update_endpoint(
47 identifier: KT,
48 patch_body: Annotated[JsonDict, Body()],
49 ) -> VT | None:
50 return await update_func(
51 identifier,
52 patch_body,
53 )
55 if delete_func:
56 @app.delete(path_prefix + "/{identifier}")
57 async def delete_endpoint(identifier: KT) -> VT | None:
58 return await delete_func(identifier)
60 if list_func:
61 @app.get(path_prefix + "/")
62 async def list_endpoint(filter_body: str | None = None) -> dict[KT, Any]:
63 try:
64 return dict(await list_func(
65 json.loads(filter_body) if filter_body
66 else None,
67 ))
68 except json.JSONDecodeError as e:
69 raise HTTPException(HTTPStatus.BAD_REQUEST) from e
72class FilterBody(BaseModel):
73 model_config = ConfigDict(extra="forbid")
75 query: Any = None
77 includes: list[str] | None = None
78 excludes: list[str] | None = None
80 def match(self, key: str) -> bool:
81 return wildcard_match(key, includes=self.includes, excludes=self.excludes)
84def add_crudl_endpoints_for_mapping[KT: str, VT: JsonDict](
85 app: FastAPI,
86 path_prefix: str,
87 mapping: Mapping[KT, VT],
88 *,
89 values_in_list: bool = False,
90 readonly: bool | None = None,
91) -> None:
92 mutable: bool = isinstance(mapping, MutableMapping)
93 if readonly is None:
94 readonly = not mutable
96 async def read_func(key: KT) -> VT:
97 try:
98 return crudl_store.read(key)
99 except KeyError as e:
100 raise HTTPException(HTTPStatus.NOT_FOUND) from e
102 async def list_func(filter_body: JsonDict | None) -> Iterable[tuple[KT, VT | None]]:
103 try:
104 filter_body_model: FilterBody | None = (
105 FilterBody.model_validate(filter_body, strict=True) if filter_body
106 else None
107 )
108 except ValidationError as e:
109 raise HTTPException(HTTPStatus.BAD_REQUEST) from e
111 if (
112 filter_body_model
113 and filter_body_model.query is not None
114 and not isinstance(mapping, SearchableMapping)
115 ):
116 raise HTTPException(HTTPStatus.BAD_REQUEST)
118 return crudl_store.list(
119 None if filter_body_model is None
120 else (
121 filter_body_model.query if isinstance(mapping, SearchableMapping) else None,
122 filter_body_model.match,
123 ),
124 )
126 if mutable and not readonly:
127 crudl_store = CRUDLWrapper[KT, VT](
128 mapping,
129 values_in_list=values_in_list,
130 )
132 async def create_func(key: KT, value: JsonDict) -> None:
133 try:
134 crudl_store.create(key, cast("VT", value))
135 except KeyError as e:
136 raise HTTPException(HTTPStatus.CONFLICT) from e
138 async def update_func(key: KT, value: JsonDict) -> None:
139 try:
140 crudl_store.update(key, cast("VT", value))
141 except KeyError as e:
142 raise HTTPException(HTTPStatus.NOT_FOUND) from e
144 async def delete_func(key: KT) -> None:
145 try:
146 crudl_store.delete(key)
147 except KeyError as e:
148 raise HTTPException(HTTPStatus.NOT_FOUND) from e
150 add_crudl_endpoints(
151 app,
152 path_prefix,
153 read_func=read_func,
154 list_func=list_func,
155 create_func=create_func,
156 update_func=update_func,
157 delete_func=delete_func,
158 )
159 else:
160 crudl_store = RLWrapper[KT, VT](
161 mapping,
162 values_in_list=values_in_list,
163 )
165 add_crudl_endpoints(
166 app,
167 path_prefix,
168 read_func=read_func,
169 list_func=list_func,
170 )