Coverage for src/extratools_api/crudl.py: 0%

72 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-06-13 19:09 -0700

1import json 

2from collections.abc import Awaitable, Callable, Iterable, Mapping, MutableMapping 

3from http import HTTPStatus 

4from typing import Annotated, Any, cast 

5 

6from extratools_core.crudl import CRUDLWrapper, RLWrapper 

7from extratools_core.json import JsonDict 

8from fastapi import Body, FastAPI, HTTPException 

9from pydantic import BaseModel, ConfigDict, ValidationError 

10 

11 

12def add_crudl_endpoints[KT: str, VT: JsonDict | BaseModel]( 

13 app: FastAPI, 

14 path_prefix: str, 

15 *, 

16 create_func: Callable[[KT, JsonDict], Awaitable[VT | None]] | None = None, 

17 read_func: Callable[[KT], Awaitable[VT]] | None = None, 

18 update_func: Callable[[KT, JsonDict], Awaitable[VT | None]] | None = None, 

19 delete_func: Callable[[KT], Awaitable[VT | None]] | None = None, 

20 list_func: Callable[[JsonDict | None], Awaitable[Iterable[tuple[KT, Any]]]] | None = None, 

21) -> None: 

22 path_prefix = path_prefix.rstrip("/") 

23 

24 if create_func: 

25 @app.put(path_prefix + "/{identifier}") 

26 async def create_endpoint( 

27 identifier: KT, 

28 put_body: Annotated[JsonDict, Body()], 

29 ) -> VT | None: 

30 return await create_func( 

31 identifier, 

32 put_body, 

33 ) 

34 

35 if read_func: 

36 @app.get(path_prefix + "/{identifier}") 

37 async def read_endpoint(identifier: KT) -> VT: 

38 return await read_func( 

39 identifier, 

40 ) 

41 

42 if update_func: 

43 @app.patch(path_prefix + "/{identifier}") 

44 async def update_endpoint( 

45 identifier: KT, 

46 patch_body: Annotated[JsonDict, Body()], 

47 ) -> VT | None: 

48 return await update_func( 

49 identifier, 

50 patch_body, 

51 ) 

52 

53 if delete_func: 

54 @app.delete(path_prefix + "/{identifier}") 

55 async def delete_endpoint(identifier: KT) -> VT | None: 

56 return await delete_func(identifier) 

57 

58 if list_func: 

59 @app.get(path_prefix + "/") 

60 async def list_endpoint(filter_body: str | None = None) -> dict[KT, Any]: 

61 try: 

62 return dict(await list_func( 

63 json.loads(filter_body) if filter_body 

64 else None, 

65 )) 

66 except json.JSONDecodeError as e: 

67 raise HTTPException(HTTPStatus.BAD_REQUEST) from e 

68 

69 

70class FilterKeys(BaseModel): 

71 model_config = ConfigDict(extra="forbid") 

72 

73 includes: list[str] | None = None 

74 excludes: list[str] | None = None 

75 

76 

77def add_crudl_endpoints_for_mapping[KT: str, VT: JsonDict]( 

78 app: FastAPI, 

79 path_prefix: str, 

80 mapping: Mapping[KT, VT], 

81 *, 

82 values_in_list: bool = False, 

83 readonly: bool | None = None, 

84) -> None: 

85 mutable: bool = isinstance(mapping, MutableMapping) 

86 if readonly is None: 

87 readonly = not mutable 

88 

89 async def read_func(key: KT) -> VT: 

90 try: 

91 return crudl_store.read(key) 

92 except KeyError as e: 

93 raise HTTPException(HTTPStatus.NOT_FOUND) from e 

94 

95 async def list_func(filter_keys: JsonDict | None) -> Iterable[tuple[KT, VT | None]]: 

96 try: 

97 filter_keys_model: FilterKeys | None = ( 

98 FilterKeys.model_validate(filter_keys, strict=True) if filter_keys 

99 else None 

100 ) 

101 except ValidationError as e: 

102 raise HTTPException(HTTPStatus.BAD_REQUEST) from e 

103 

104 return crudl_store.list( 

105 None if filter_keys_model is None 

106 else lambda key: ( 

107 (filter_keys_model.includes is None or key in (filter_keys_model.includes)) 

108 and key not in (filter_keys_model.excludes or []) 

109 ), 

110 ) 

111 

112 if mutable and not readonly: 

113 crudl_store = CRUDLWrapper[KT, VT]( 

114 mapping, 

115 values_in_list=values_in_list, 

116 ) 

117 

118 async def create_func(key: KT, value: JsonDict) -> None: 

119 try: 

120 crudl_store.create(key, cast("VT", value)) 

121 except KeyError as e: 

122 raise HTTPException(HTTPStatus.CONFLICT) from e 

123 

124 async def update_func(key: KT, value: JsonDict) -> None: 

125 try: 

126 crudl_store.update(key, cast("VT", value)) 

127 except KeyError as e: 

128 raise HTTPException(HTTPStatus.NOT_FOUND) from e 

129 

130 async def delete_func(key: KT) -> None: 

131 try: 

132 crudl_store.delete(key) 

133 except KeyError as e: 

134 raise HTTPException(HTTPStatus.NOT_FOUND) from e 

135 

136 add_crudl_endpoints( 

137 app, 

138 path_prefix, 

139 read_func=read_func, 

140 list_func=list_func, 

141 create_func=create_func, 

142 update_func=update_func, 

143 delete_func=delete_func, 

144 ) 

145 else: 

146 crudl_store = RLWrapper[KT, VT]( 

147 mapping, 

148 values_in_list=values_in_list, 

149 ) 

150 

151 add_crudl_endpoints( 

152 app, 

153 path_prefix, 

154 read_func=read_func, 

155 list_func=list_func, 

156 )