clear-skies 2.0.27__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of clear-skies might be problematic. Click here for more details.

Files changed (270) hide show
  1. clear_skies-2.0.27.dist-info/METADATA +78 -0
  2. clear_skies-2.0.27.dist-info/RECORD +270 -0
  3. clear_skies-2.0.27.dist-info/WHEEL +4 -0
  4. clear_skies-2.0.27.dist-info/licenses/LICENSE +7 -0
  5. clearskies/__init__.py +69 -0
  6. clearskies/action.py +7 -0
  7. clearskies/authentication/__init__.py +15 -0
  8. clearskies/authentication/authentication.py +44 -0
  9. clearskies/authentication/authorization.py +23 -0
  10. clearskies/authentication/authorization_pass_through.py +22 -0
  11. clearskies/authentication/jwks.py +165 -0
  12. clearskies/authentication/public.py +5 -0
  13. clearskies/authentication/secret_bearer.py +551 -0
  14. clearskies/autodoc/__init__.py +8 -0
  15. clearskies/autodoc/formats/__init__.py +5 -0
  16. clearskies/autodoc/formats/oai3_json/__init__.py +7 -0
  17. clearskies/autodoc/formats/oai3_json/oai3_json.py +87 -0
  18. clearskies/autodoc/formats/oai3_json/oai3_schema_resolver.py +15 -0
  19. clearskies/autodoc/formats/oai3_json/parameter.py +35 -0
  20. clearskies/autodoc/formats/oai3_json/request.py +68 -0
  21. clearskies/autodoc/formats/oai3_json/response.py +28 -0
  22. clearskies/autodoc/formats/oai3_json/schema/__init__.py +11 -0
  23. clearskies/autodoc/formats/oai3_json/schema/array.py +9 -0
  24. clearskies/autodoc/formats/oai3_json/schema/default.py +13 -0
  25. clearskies/autodoc/formats/oai3_json/schema/enum.py +7 -0
  26. clearskies/autodoc/formats/oai3_json/schema/object.py +35 -0
  27. clearskies/autodoc/formats/oai3_json/test.json +1985 -0
  28. clearskies/autodoc/py.typed +0 -0
  29. clearskies/autodoc/request/__init__.py +15 -0
  30. clearskies/autodoc/request/header.py +6 -0
  31. clearskies/autodoc/request/json_body.py +6 -0
  32. clearskies/autodoc/request/parameter.py +8 -0
  33. clearskies/autodoc/request/request.py +47 -0
  34. clearskies/autodoc/request/url_parameter.py +6 -0
  35. clearskies/autodoc/request/url_path.py +6 -0
  36. clearskies/autodoc/response/__init__.py +5 -0
  37. clearskies/autodoc/response/response.py +9 -0
  38. clearskies/autodoc/schema/__init__.py +31 -0
  39. clearskies/autodoc/schema/array.py +10 -0
  40. clearskies/autodoc/schema/base64.py +8 -0
  41. clearskies/autodoc/schema/boolean.py +5 -0
  42. clearskies/autodoc/schema/date.py +5 -0
  43. clearskies/autodoc/schema/datetime.py +5 -0
  44. clearskies/autodoc/schema/double.py +5 -0
  45. clearskies/autodoc/schema/enum.py +17 -0
  46. clearskies/autodoc/schema/integer.py +6 -0
  47. clearskies/autodoc/schema/long.py +5 -0
  48. clearskies/autodoc/schema/number.py +6 -0
  49. clearskies/autodoc/schema/object.py +13 -0
  50. clearskies/autodoc/schema/password.py +5 -0
  51. clearskies/autodoc/schema/schema.py +11 -0
  52. clearskies/autodoc/schema/string.py +5 -0
  53. clearskies/backends/__init__.py +67 -0
  54. clearskies/backends/api_backend.py +1194 -0
  55. clearskies/backends/backend.py +137 -0
  56. clearskies/backends/cursor_backend.py +339 -0
  57. clearskies/backends/graphql_backend.py +977 -0
  58. clearskies/backends/memory_backend.py +794 -0
  59. clearskies/backends/secrets_backend.py +100 -0
  60. clearskies/clients/__init__.py +5 -0
  61. clearskies/clients/graphql_client.py +182 -0
  62. clearskies/column.py +1221 -0
  63. clearskies/columns/__init__.py +71 -0
  64. clearskies/columns/audit.py +306 -0
  65. clearskies/columns/belongs_to_id.py +478 -0
  66. clearskies/columns/belongs_to_model.py +145 -0
  67. clearskies/columns/belongs_to_self.py +109 -0
  68. clearskies/columns/boolean.py +110 -0
  69. clearskies/columns/category_tree.py +274 -0
  70. clearskies/columns/category_tree_ancestors.py +51 -0
  71. clearskies/columns/category_tree_children.py +125 -0
  72. clearskies/columns/category_tree_descendants.py +48 -0
  73. clearskies/columns/created.py +92 -0
  74. clearskies/columns/created_by_authorization_data.py +114 -0
  75. clearskies/columns/created_by_header.py +103 -0
  76. clearskies/columns/created_by_ip.py +90 -0
  77. clearskies/columns/created_by_routing_data.py +102 -0
  78. clearskies/columns/created_by_user_agent.py +89 -0
  79. clearskies/columns/date.py +232 -0
  80. clearskies/columns/datetime.py +284 -0
  81. clearskies/columns/email.py +78 -0
  82. clearskies/columns/float.py +149 -0
  83. clearskies/columns/has_many.py +552 -0
  84. clearskies/columns/has_many_self.py +62 -0
  85. clearskies/columns/has_one.py +21 -0
  86. clearskies/columns/integer.py +158 -0
  87. clearskies/columns/json.py +126 -0
  88. clearskies/columns/many_to_many_ids.py +335 -0
  89. clearskies/columns/many_to_many_ids_with_data.py +281 -0
  90. clearskies/columns/many_to_many_models.py +163 -0
  91. clearskies/columns/many_to_many_pivots.py +132 -0
  92. clearskies/columns/phone.py +162 -0
  93. clearskies/columns/select.py +95 -0
  94. clearskies/columns/string.py +102 -0
  95. clearskies/columns/timestamp.py +164 -0
  96. clearskies/columns/updated.py +107 -0
  97. clearskies/columns/uuid.py +83 -0
  98. clearskies/configs/README.md +105 -0
  99. clearskies/configs/__init__.py +170 -0
  100. clearskies/configs/actions.py +43 -0
  101. clearskies/configs/any.py +15 -0
  102. clearskies/configs/any_dict.py +24 -0
  103. clearskies/configs/any_dict_or_callable.py +25 -0
  104. clearskies/configs/authentication.py +23 -0
  105. clearskies/configs/authorization.py +23 -0
  106. clearskies/configs/boolean.py +18 -0
  107. clearskies/configs/boolean_or_callable.py +20 -0
  108. clearskies/configs/callable_config.py +20 -0
  109. clearskies/configs/columns.py +34 -0
  110. clearskies/configs/conditions.py +30 -0
  111. clearskies/configs/config.py +26 -0
  112. clearskies/configs/datetime.py +20 -0
  113. clearskies/configs/datetime_or_callable.py +21 -0
  114. clearskies/configs/email.py +10 -0
  115. clearskies/configs/email_list.py +17 -0
  116. clearskies/configs/email_list_or_callable.py +17 -0
  117. clearskies/configs/email_or_email_list_or_callable.py +59 -0
  118. clearskies/configs/endpoint.py +23 -0
  119. clearskies/configs/endpoint_list.py +29 -0
  120. clearskies/configs/float.py +18 -0
  121. clearskies/configs/float_or_callable.py +20 -0
  122. clearskies/configs/headers.py +28 -0
  123. clearskies/configs/integer.py +18 -0
  124. clearskies/configs/integer_or_callable.py +20 -0
  125. clearskies/configs/joins.py +30 -0
  126. clearskies/configs/list_any_dict.py +32 -0
  127. clearskies/configs/list_any_dict_or_callable.py +33 -0
  128. clearskies/configs/model_class.py +35 -0
  129. clearskies/configs/model_column.py +67 -0
  130. clearskies/configs/model_columns.py +58 -0
  131. clearskies/configs/model_destination_name.py +26 -0
  132. clearskies/configs/model_to_id_column.py +45 -0
  133. clearskies/configs/readable_model_column.py +11 -0
  134. clearskies/configs/readable_model_columns.py +11 -0
  135. clearskies/configs/schema.py +23 -0
  136. clearskies/configs/searchable_model_columns.py +11 -0
  137. clearskies/configs/security_headers.py +39 -0
  138. clearskies/configs/select.py +28 -0
  139. clearskies/configs/select_list.py +49 -0
  140. clearskies/configs/string.py +31 -0
  141. clearskies/configs/string_dict.py +34 -0
  142. clearskies/configs/string_list.py +47 -0
  143. clearskies/configs/string_list_or_callable.py +48 -0
  144. clearskies/configs/string_or_callable.py +18 -0
  145. clearskies/configs/timedelta.py +20 -0
  146. clearskies/configs/timezone.py +20 -0
  147. clearskies/configs/url.py +25 -0
  148. clearskies/configs/validators.py +45 -0
  149. clearskies/configs/writeable_model_column.py +11 -0
  150. clearskies/configs/writeable_model_columns.py +11 -0
  151. clearskies/configurable.py +78 -0
  152. clearskies/contexts/__init__.py +11 -0
  153. clearskies/contexts/cli.py +130 -0
  154. clearskies/contexts/context.py +99 -0
  155. clearskies/contexts/wsgi.py +79 -0
  156. clearskies/contexts/wsgi_ref.py +87 -0
  157. clearskies/cursors/__init__.py +10 -0
  158. clearskies/cursors/cursor.py +161 -0
  159. clearskies/cursors/from_environment/__init__.py +5 -0
  160. clearskies/cursors/from_environment/mysql.py +51 -0
  161. clearskies/cursors/from_environment/postgresql.py +49 -0
  162. clearskies/cursors/from_environment/sqlite.py +35 -0
  163. clearskies/cursors/mysql.py +61 -0
  164. clearskies/cursors/postgresql.py +61 -0
  165. clearskies/cursors/sqlite.py +62 -0
  166. clearskies/decorators.py +33 -0
  167. clearskies/decorators.pyi +10 -0
  168. clearskies/di/__init__.py +15 -0
  169. clearskies/di/additional_config.py +130 -0
  170. clearskies/di/additional_config_auto_import.py +17 -0
  171. clearskies/di/di.py +948 -0
  172. clearskies/di/inject/__init__.py +25 -0
  173. clearskies/di/inject/akeyless_sdk.py +16 -0
  174. clearskies/di/inject/by_class.py +24 -0
  175. clearskies/di/inject/by_name.py +22 -0
  176. clearskies/di/inject/di.py +16 -0
  177. clearskies/di/inject/environment.py +15 -0
  178. clearskies/di/inject/input_output.py +19 -0
  179. clearskies/di/inject/logger.py +16 -0
  180. clearskies/di/inject/now.py +16 -0
  181. clearskies/di/inject/requests.py +16 -0
  182. clearskies/di/inject/secrets.py +15 -0
  183. clearskies/di/inject/utcnow.py +16 -0
  184. clearskies/di/inject/uuid.py +16 -0
  185. clearskies/di/injectable.py +32 -0
  186. clearskies/di/injectable_properties.py +131 -0
  187. clearskies/end.py +219 -0
  188. clearskies/endpoint.py +1303 -0
  189. clearskies/endpoint_group.py +333 -0
  190. clearskies/endpoints/__init__.py +25 -0
  191. clearskies/endpoints/advanced_search.py +519 -0
  192. clearskies/endpoints/callable.py +382 -0
  193. clearskies/endpoints/create.py +201 -0
  194. clearskies/endpoints/delete.py +133 -0
  195. clearskies/endpoints/get.py +267 -0
  196. clearskies/endpoints/health_check.py +181 -0
  197. clearskies/endpoints/list.py +567 -0
  198. clearskies/endpoints/restful_api.py +417 -0
  199. clearskies/endpoints/schema.py +185 -0
  200. clearskies/endpoints/simple_search.py +279 -0
  201. clearskies/endpoints/update.py +188 -0
  202. clearskies/environment.py +106 -0
  203. clearskies/exceptions/__init__.py +19 -0
  204. clearskies/exceptions/authentication.py +2 -0
  205. clearskies/exceptions/authorization.py +2 -0
  206. clearskies/exceptions/client_error.py +2 -0
  207. clearskies/exceptions/input_errors.py +4 -0
  208. clearskies/exceptions/missing_dependency.py +2 -0
  209. clearskies/exceptions/moved_permanently.py +3 -0
  210. clearskies/exceptions/moved_temporarily.py +3 -0
  211. clearskies/exceptions/not_found.py +2 -0
  212. clearskies/functional/__init__.py +7 -0
  213. clearskies/functional/json.py +47 -0
  214. clearskies/functional/routing.py +92 -0
  215. clearskies/functional/string.py +112 -0
  216. clearskies/functional/validations.py +76 -0
  217. clearskies/input_outputs/__init__.py +13 -0
  218. clearskies/input_outputs/cli.py +157 -0
  219. clearskies/input_outputs/exceptions/__init__.py +7 -0
  220. clearskies/input_outputs/exceptions/cli_input_error.py +2 -0
  221. clearskies/input_outputs/exceptions/cli_not_found.py +2 -0
  222. clearskies/input_outputs/headers.py +54 -0
  223. clearskies/input_outputs/input_output.py +116 -0
  224. clearskies/input_outputs/programmatic.py +62 -0
  225. clearskies/input_outputs/py.typed +0 -0
  226. clearskies/input_outputs/wsgi.py +80 -0
  227. clearskies/loggable.py +19 -0
  228. clearskies/model.py +2039 -0
  229. clearskies/py.typed +0 -0
  230. clearskies/query/__init__.py +12 -0
  231. clearskies/query/condition.py +228 -0
  232. clearskies/query/join.py +136 -0
  233. clearskies/query/query.py +195 -0
  234. clearskies/query/sort.py +27 -0
  235. clearskies/schema.py +82 -0
  236. clearskies/secrets/__init__.py +7 -0
  237. clearskies/secrets/additional_configs/__init__.py +32 -0
  238. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +61 -0
  239. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +160 -0
  240. clearskies/secrets/akeyless.py +507 -0
  241. clearskies/secrets/exceptions/__init__.py +7 -0
  242. clearskies/secrets/exceptions/not_found_error.py +2 -0
  243. clearskies/secrets/exceptions/permissions_error.py +2 -0
  244. clearskies/secrets/secrets.py +39 -0
  245. clearskies/security_header.py +17 -0
  246. clearskies/security_headers/__init__.py +11 -0
  247. clearskies/security_headers/cache_control.py +68 -0
  248. clearskies/security_headers/cors.py +51 -0
  249. clearskies/security_headers/csp.py +95 -0
  250. clearskies/security_headers/hsts.py +23 -0
  251. clearskies/security_headers/x_content_type_options.py +0 -0
  252. clearskies/security_headers/x_frame_options.py +0 -0
  253. clearskies/typing.py +11 -0
  254. clearskies/validator.py +36 -0
  255. clearskies/validators/__init__.py +33 -0
  256. clearskies/validators/after_column.py +61 -0
  257. clearskies/validators/before_column.py +15 -0
  258. clearskies/validators/in_the_future.py +29 -0
  259. clearskies/validators/in_the_future_at_least.py +13 -0
  260. clearskies/validators/in_the_future_at_most.py +12 -0
  261. clearskies/validators/in_the_past.py +29 -0
  262. clearskies/validators/in_the_past_at_least.py +12 -0
  263. clearskies/validators/in_the_past_at_most.py +12 -0
  264. clearskies/validators/maximum_length.py +25 -0
  265. clearskies/validators/maximum_value.py +28 -0
  266. clearskies/validators/minimum_length.py +25 -0
  267. clearskies/validators/minimum_value.py +28 -0
  268. clearskies/validators/required.py +32 -0
  269. clearskies/validators/timedelta.py +58 -0
  270. clearskies/validators/unique.py +28 -0
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Callable
4
+
5
+ from clearskies import authentication, autodoc, decorators, exceptions
6
+ from clearskies.endpoints.list import List
7
+
8
+ if TYPE_CHECKING:
9
+ from clearskies import Column, Model, Schema, SecurityHeader, typing
10
+
11
+
12
+ class SimpleSearch(List):
13
+ """
14
+ Create an endpoint that supports searching by exact values via url/JSON parameters.
15
+
16
+ This acts exactly like the list endpoint but additionally grants the client the ability to search records
17
+ via URL parameters or JSON POST body parameters. You just have to specify which columns are searchable.
18
+
19
+ In the following example we tell the `SimpleSearch` endpoint that we want it to return records from the
20
+ `Student` model, return `id`, `name`, and `grade` in the results, and allow the user to search by
21
+ `name` and `grade`. We also seed the memory backend with data so the endpoint has something to return:
22
+
23
+ ```python
24
+ import clearskies
25
+
26
+
27
+ class Student(clearskies.Model):
28
+ backend = clearskies.backends.MemoryBackend()
29
+ id_column_name = "id"
30
+
31
+ id = clearskies.columns.Uuid()
32
+ name = clearskies.columns.String()
33
+ grade = clearskies.columns.Integer()
34
+
35
+
36
+ wsgi = clearskies.contexts.WsgiRef(
37
+ clearskies.endpoints.SimpleSearch(
38
+ Student,
39
+ readable_column_names=["id", "name", "grade"],
40
+ sortable_column_names=["name", "grade"],
41
+ searchable_column_names=["name", "grade"],
42
+ default_sort_column_name="name",
43
+ ),
44
+ bindings={
45
+ "memory_backend_default_data": [
46
+ {
47
+ "model_class": Student,
48
+ "records": [
49
+ {"id": "1-2-3-4", "name": "Bob", "grade": 5},
50
+ {"id": "1-2-3-5", "name": "Jane", "grade": 3},
51
+ {"id": "1-2-3-6", "name": "Greg", "grade": 3},
52
+ {"id": "1-2-3-7", "name": "Bob", "grade": 2},
53
+ ],
54
+ },
55
+ ],
56
+ },
57
+ )
58
+ wsgi()
59
+ ```
60
+
61
+ Here is the basic operation of the endpoint itself, without any search parameters, in which case it behaves
62
+ identically to the list endpoint:
63
+
64
+ ```bash
65
+ $ curl 'http://localhost:8080' | jq
66
+ {
67
+ "status": "success",
68
+ "error": "",
69
+ "data": [
70
+ {
71
+ "id": "1-2-3-4",
72
+ "name": "Bob",
73
+ "grade": 5
74
+ },
75
+ {
76
+ "id": "1-2-3-7",
77
+ "name": "Bob",
78
+ "grade": 2
79
+ },
80
+ {
81
+ "id": "1-2-3-6",
82
+ "name": "Greg",
83
+ "grade": 3
84
+ },
85
+ {
86
+ "id": "1-2-3-5",
87
+ "name": "Jane",
88
+ "grade": 3
89
+ }
90
+ ],
91
+ "pagination": {},
92
+ "input_errors": {}
93
+ }
94
+ ```
95
+
96
+ We can then search on name via the `name` URL parameter:
97
+
98
+ ```bash
99
+ $ curl 'http://localhost:8080?name=Bob' | jq
100
+ {
101
+ "status": "success",
102
+ "error": "",
103
+ "data": [
104
+ {
105
+ "id": "1-2-3-4",
106
+ "name": "Bob",
107
+ "grade": 5
108
+ },
109
+ {
110
+ "id": "1-2-3-7",
111
+ "name": "Bob",
112
+ "grade": 2
113
+ }
114
+ ],
115
+ "pagination": {},
116
+ "input_errors": {}
117
+ }
118
+ ```
119
+
120
+ and multiple search terms are allowed:
121
+
122
+ ```bash
123
+ $ curl 'http://localhost:8080?name=Bob&grade=2' | jq
124
+ {
125
+ "status": "success",
126
+ "error": "",
127
+ "data": [
128
+ {
129
+ "id": "1-2-3-7",
130
+ "name": "Bob",
131
+ "grade": 2
132
+ }
133
+ ],
134
+ "pagination": {},
135
+ "input_errors": {}
136
+ }
137
+ ```
138
+
139
+ Pagination and sorting work just like with the list endpoint:
140
+
141
+ ```bash
142
+ $ curl 'http://localhost:8080?sort=grade&direction=desc&limit=2' | jq
143
+ {
144
+ "status": "success",
145
+ "error": "",
146
+ "data": [
147
+ {
148
+ "id": "1-2-3-4",
149
+ "name": "Bob",
150
+ "grade": 5
151
+ },
152
+ {
153
+ "id": "1-2-3-5",
154
+ "name": "Jane",
155
+ "grade": 3
156
+ }
157
+ ],
158
+ "pagination": {
159
+ "number_results": 4,
160
+ "limit": 2,
161
+ "next_page": {
162
+ "start": 2
163
+ }
164
+ },
165
+ "input_errors": {}
166
+ }
167
+
168
+ $ curl 'http://localhost:8080?sort=grade&direction=desc&limit=2&start=2' | jq
169
+ {
170
+ "status": "success",
171
+ "error": "",
172
+ "data": [
173
+ {
174
+ "id": "1-2-3-6",
175
+ "name": "Greg",
176
+ "grade": 3
177
+ },
178
+ {
179
+ "id": "1-2-3-7",
180
+ "name": "Bob",
181
+ "grade": 2
182
+ }
183
+ ],
184
+ "pagination": {},
185
+ "input_errors": {}
186
+ }
187
+ ```
188
+ """
189
+
190
+ @decorators.parameters_to_properties
191
+ def __init__(
192
+ self,
193
+ model_class: type[Model],
194
+ readable_column_names: list[str],
195
+ sortable_column_names: list[str],
196
+ searchable_column_names: list[str],
197
+ default_sort_column_name: str,
198
+ default_sort_direction: str = "ASC",
199
+ default_limit: int = 50,
200
+ maximum_limit: int = 200,
201
+ where: typing.condition | list[typing.condition] = [],
202
+ joins: typing.join | list[typing.join] = [],
203
+ url: str = "",
204
+ request_methods: list[str] = ["GET", "POST", "QUERY"],
205
+ response_headers: list[str | Callable[..., list[str]]] = [],
206
+ output_map: Callable[..., dict[str, Any]] | None = None,
207
+ output_schema: Schema | None = None,
208
+ column_overrides: dict[str, Column] = {},
209
+ internal_casing: str = "snake_case",
210
+ external_casing: str = "snake_case",
211
+ security_headers: list[SecurityHeader] = [],
212
+ description: str = "",
213
+ authentication: authentication.Authentication = authentication.Public(),
214
+ authorization: authentication.Authorization = authentication.Authorization(),
215
+ ):
216
+ self.request_methods = request_methods
217
+
218
+ # we need to call the parent but don't have to pass along any of our kwargs. They are all optional in our parent, and our parent class
219
+ # just stores them in parameters, which we have already done. However, the parent does do some extra initialization stuff that we need,
220
+ # which is why we have to call the parent.
221
+ super().__init__(model_class, readable_column_names, sortable_column_names, default_sort_column_name)
222
+
223
+ def check_search_in_request_data(self, request_data: dict[str, Any], query_parameters: dict[str, Any]) -> None:
224
+ for input_source_label, input_data in [("request body", request_data), ("URL data", query_parameters)]:
225
+ for column_name, value in input_data.items():
226
+ if column_name in self.allowed_request_keys and column_name not in self.searchable_column_names:
227
+ continue
228
+ if column_name not in self.searchable_column_names:
229
+ raise exceptions.ClientError(
230
+ f"Invalid request parameter found in {input_source_label}: '{column_name}'"
231
+ )
232
+ [relationship_column_name, final_column_name] = self.unpack_column_name_with_relationship(column_name)
233
+ column_to_check = relationship_column_name if relationship_column_name else final_column_name
234
+ value_error = self.searchable_columns[column_to_check].check_search_value(
235
+ value, relationship_reference=final_column_name
236
+ )
237
+ if value_error:
238
+ raise exceptions.InputErrors({column_name: value_error})
239
+
240
+ def configure_model_from_request_data(
241
+ self,
242
+ model: Model,
243
+ request_data: dict[str, Any],
244
+ query_parameters: dict[str, Any],
245
+ pagination_data: dict[str, Any],
246
+ ) -> Model:
247
+ model = super().configure_model_from_request_data(
248
+ model,
249
+ request_data,
250
+ query_parameters,
251
+ pagination_data,
252
+ )
253
+
254
+ for input_source in [request_data, query_parameters]:
255
+ for column_name, value in input_source.items():
256
+ if column_name not in self.searchable_column_names:
257
+ continue
258
+
259
+ model = self.add_join(column_name, model)
260
+ [relationship_column_name, column_name] = self.unpack_column_name_with_relationship(column_name)
261
+ if relationship_column_name:
262
+ self.columns[relationship_column_name].add_search(model, value, relationship_reference=column_name)
263
+ else:
264
+ model = self.columns[column_name].add_search(model, value, operator="=")
265
+
266
+ return model
267
+
268
+ def documentation_url_search_parameters(self) -> list[autodoc.request.Parameter]:
269
+ docs = []
270
+ for column in self.searchable_columns.values():
271
+ column_doc = column.documentation()[0]
272
+ column_doc.name = self.auto_case_internal_column_name(column_doc.name)
273
+ docs.append(
274
+ autodoc.request.URLParameter(
275
+ column_doc,
276
+ description=f"Search by {column_doc.name} (via exact match)",
277
+ )
278
+ )
279
+ return docs # type: ignore
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Callable
4
+
5
+ from clearskies import authentication, autodoc, decorators, exceptions
6
+ from clearskies.endpoints.get import Get
7
+ from clearskies.functional import string
8
+ from clearskies.input_outputs import InputOutput
9
+
10
+ if TYPE_CHECKING:
11
+ from clearskies import Column, Model, Schema, SecurityHeader, typing
12
+
13
+
14
+ class Update(Get):
15
+ """
16
+ An endpoint to update a record.
17
+
18
+ This endpoint handles update operations. As with the `Get` endpoint, it will lookup the record by taking
19
+ the record id (or any other unique column you specify) out of the URL and then will fetch that record
20
+ using the model class. Then, it will use the model and list of writeable column names to validate the
21
+ incoming user input. The default request method is `PATCH`. If everything checks out, it will then
22
+ update the record.
23
+
24
+ ```python
25
+ import clearskies
26
+
27
+
28
+ class User(clearskies.Model):
29
+ id_column_name = "id"
30
+ backend = clearskies.backends.MemoryBackend()
31
+ id = clearskies.columns.Uuid()
32
+ name = clearskies.columns.String()
33
+ username = clearskies.columns.String(validators=[clearskies.validators.Required()])
34
+
35
+
36
+ wsgi = clearskies.contexts.WsgiRef(
37
+ clearskies.endpoints.Update(
38
+ model_class=User,
39
+ url="/{id}",
40
+ readable_column_names=["id", "name", "username"],
41
+ writeable_column_names=["name", "username"],
42
+ ),
43
+ bindings={
44
+ "memory_backend_default_data": [
45
+ {
46
+ "model_class": User,
47
+ "records": [
48
+ {"id": "1-2-3-4", "name": "Bob Brown", "username": "bobbrown"},
49
+ {"id": "1-2-3-5", "name": "Jane Doe", "username": "janedoe"},
50
+ {"id": "1-2-3-6", "name": "Greg", "username": "greg"},
51
+ ],
52
+ },
53
+ ],
54
+ },
55
+ )
56
+ wsgi()
57
+ ```
58
+
59
+ And when invoked:
60
+
61
+ ```bash
62
+ $ curl 'http://localhost:8080/1-2-3-4' -X PATCH -d '{"name": "Bobby Brown", "username": "bobbybrown"}' | jq
63
+ {
64
+ "status": "success",
65
+ "error": "",
66
+ "data": {
67
+ "id": "1-2-3-4",
68
+ "name": "Bobby Brown",
69
+ "username": "bobbybrown"
70
+ },
71
+ "pagination": {},
72
+ "input_errors": {}
73
+ }
74
+
75
+ $ curl 'http://localhost:8080/1-2-3-5' -X PATCH -d '{"name": 12345, "username": ""}' | jq
76
+ {
77
+ "status": "input_errors",
78
+ "error": "",
79
+ "data": [],
80
+ "pagination": {},
81
+ "input_errors": {
82
+ "name": "value should be a string",
83
+ "username": "'username' is required."
84
+ }
85
+ }
86
+ """
87
+
88
+ @decorators.parameters_to_properties
89
+ def __init__(
90
+ self,
91
+ model_class: type[Model],
92
+ url: str,
93
+ writeable_column_names: list[str],
94
+ readable_column_names: list[str],
95
+ record_lookup_column_name: str | None = None,
96
+ input_validation_callable: Callable | None = None,
97
+ request_methods: list[str] = ["PATCH"],
98
+ response_headers: list[str | Callable[..., list[str]]] = [],
99
+ output_map: Callable[..., dict[str, Any]] | None = None,
100
+ output_schema: Schema | None = None,
101
+ column_overrides: dict[str, Column] = {},
102
+ internal_casing: str = "snake_case",
103
+ external_casing: str = "snake_case",
104
+ security_headers: list[SecurityHeader] = [],
105
+ description: str = "",
106
+ where: typing.condition | list[typing.condition] = [],
107
+ joins: typing.join | list[typing.join] = [],
108
+ authentication: authentication.Authentication = authentication.Public(),
109
+ authorization: authentication.Authorization = authentication.Authorization(),
110
+ ):
111
+ # see comment in clearskies.endpoints.Create.__init__
112
+ self.request_methods = request_methods
113
+
114
+ # we need to call the parent but don't have to pass along any of our kwargs. They are all optional in our parent, and our parent class
115
+ # just stores them in parameters, which we have already done. However, the parent does do some extra initialization stuff that we need,
116
+ # which is why we have to call the parent.
117
+ super().__init__(model_class, url, readable_column_names)
118
+
119
+ def handle(self, input_output: InputOutput) -> Any:
120
+ request_data = self.get_request_data(input_output)
121
+ if not request_data and input_output.has_body():
122
+ raise exceptions.ClientError("Request body was not valid JSON")
123
+ model = self.fetch_model(input_output)
124
+ self.validate_input_against_schema(request_data, input_output, model)
125
+ model.save(request_data)
126
+ return self.success(input_output, self.model_as_json(model, input_output))
127
+
128
+ def documentation(self) -> list[autodoc.request.Request]:
129
+ output_schema = self.model_class
130
+ nice_model = string.camel_case_to_words(output_schema.__name__)
131
+
132
+ schema_model_name = string.camel_case_to_snake_case(output_schema.__name__)
133
+ output_data_schema = self.documentation_data_schema(output_schema, self.readable_column_names)
134
+ output_autodoc = (
135
+ autodoc.schema.Object(
136
+ self.auto_case_internal_column_name("data"), children=output_data_schema, model_name=schema_model_name
137
+ ),
138
+ )
139
+
140
+ authentication = self.authentication
141
+ # Many swagger UIs will only allow one response per status code, and we use the same status code (200)
142
+ # for both a success response and an input error response. This could be fixed by changing the status
143
+ # code for input error responses, but there's not actually a great HTTP status code for that, so :shrug:
144
+ # standard_error_responses = [self.documentation_input_error_response()]
145
+ standard_error_responses = []
146
+ if not getattr(authentication, "is_public", False):
147
+ standard_error_responses.append(self.documentation_access_denied_response())
148
+ if getattr(authentication, "can_authorize", False):
149
+ standard_error_responses.append(self.documentation_unauthorized_response())
150
+
151
+ return [
152
+ autodoc.request.Request(
153
+ self.description,
154
+ [
155
+ self.documentation_success_response(
156
+ output_autodoc, # type: ignore
157
+ description=self.description,
158
+ ),
159
+ *standard_error_responses,
160
+ self.documentation_generic_error_response(),
161
+ ],
162
+ relative_path=self.url,
163
+ request_methods=self.request_methods,
164
+ parameters=[
165
+ *self.documentation_request_parameters(),
166
+ *self.documentation_url_parameters(),
167
+ ],
168
+ root_properties={
169
+ "security": self.documentation_request_security(),
170
+ },
171
+ ),
172
+ ]
173
+
174
+ def documentation_request_parameters(self) -> list[autodoc.request.Parameter]:
175
+ return [
176
+ *self.standard_json_request_parameters(self.model_class),
177
+ ]
178
+
179
+ def documentation_models(self) -> dict[str, autodoc.schema.Schema]:
180
+ output_schema = self.output_schema if self.output_schema else self.model_class
181
+ schema_model_name = string.camel_case_to_snake_case(output_schema.__name__)
182
+
183
+ return {
184
+ schema_model_name: autodoc.schema.Object(
185
+ self.auto_case_internal_column_name("data"),
186
+ children=self.documentation_data_schema(output_schema, self.readable_column_names),
187
+ ),
188
+ }
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import os.path
4
+ from typing import Any
5
+
6
+
7
+ class Environment:
8
+ """
9
+ This loads up the environment configuration for the application.
10
+
11
+ It looks in 3 possible places: first, it looks in os.environ. Next, it tries to load up the .env file.
12
+ Therefore, the application root directory should be passed in, at will look for a .env file there.
13
+ It should contain lines like NAME=value. Finally, if there is a value of `secret://path/to/secret`,
14
+ it will use the secret service to look up the secret value.
15
+
16
+ It is a very basic parser. Empty lines and lines starting with a # will be ignored. Otherwise everything
17
+ is assumed to be a string.
18
+ """
19
+
20
+ _env_file_config: dict[str, Any] = None # type: ignore
21
+ _resolved_values: dict[str, Any] = {}
22
+ os_environ: dict[str, Any] = {}
23
+
24
+ def __init__(self, env_file_path, os_environ, secrets):
25
+ self._env_file_path = env_file_path
26
+ self.os_environ = os_environ
27
+ self.secrets = secrets
28
+ self._resolved_values = {}
29
+
30
+ def get(self, name, silent=False) -> Any:
31
+ self._load_env_file()
32
+ if name in self.os_environ:
33
+ return self.resolve_value(self.os_environ[name])
34
+ if name in self._env_file_config:
35
+ return self.resolve_value(self._env_file_config[name])
36
+
37
+ if not silent:
38
+ raise KeyError(f"Could not find environment config '{name}' in environment or .env file")
39
+ return None
40
+
41
+ def _load_env_file(self):
42
+ if self._env_file_config is not None:
43
+ return
44
+
45
+ self._env_file_config = {}
46
+ if not os.path.isfile(self._env_file_path):
47
+ return
48
+
49
+ with open(self._env_file_path, "r") as env_file:
50
+ line_number = 0
51
+ for line in env_file.readlines():
52
+ line_number += 1
53
+ (key, value) = self._parse_env_line(line, line_number)
54
+ if key is None:
55
+ continue
56
+
57
+ self._env_file_config[key] = value
58
+
59
+ def _parse_env_line(self, line, line_number):
60
+ line = line.strip()
61
+ if not line:
62
+ return (None, None)
63
+ if line[0] == "#":
64
+ return (None, None)
65
+ if not "=" in line:
66
+ raise ValueError(f"Parse error in environment line #{line_number}: should be 'key=value'")
67
+
68
+ equal_index = line.index("=")
69
+ key = line[:equal_index].strip()
70
+ value = line[equal_index + 1 :].strip()
71
+ lc_value = value.lower()
72
+ if lc_value == "true":
73
+ return (key, True)
74
+ if lc_value == "false":
75
+ return (key, False)
76
+ if lc_value[0] == '"' and lc_value[-1] == '"':
77
+ return (key, value.strip('"'))
78
+ if lc_value[0] == "'" and lc_value[-1] == "'":
79
+ return (key, value.strip("'"))
80
+ try:
81
+ as_int = int(value)
82
+ return (key, as_int)
83
+ except:
84
+ pass
85
+ try:
86
+ as_float = float(value)
87
+ return (key, as_float)
88
+ except:
89
+ pass
90
+ return (key, value)
91
+
92
+ def resolve_value(self, value):
93
+ if type(value) != str or value[:9] != "secret://":
94
+ return value
95
+
96
+ secret_path = value[9:]
97
+ if secret_path[0] != "/":
98
+ secret_path = f"/{secret_path}"
99
+ if secret_path not in self._resolved_values:
100
+ if not self.secrets:
101
+ raise ValueError(
102
+ "References to the secret engine were found in the environment, "
103
+ + "but a secret engine was not provided"
104
+ )
105
+ self._resolved_values[secret_path] = self.secrets.get(secret_path)
106
+ return self._resolved_values[secret_path]
@@ -0,0 +1,19 @@
1
+ from clearskies.exceptions.authentication import Authentication
2
+ from clearskies.exceptions.authorization import Authorization
3
+ from clearskies.exceptions.client_error import ClientError
4
+ from clearskies.exceptions.input_errors import InputErrors
5
+ from clearskies.exceptions.missing_dependency import MissingDependency
6
+ from clearskies.exceptions.moved_permanently import MovedPermanently
7
+ from clearskies.exceptions.moved_temporarily import MovedTemporarily
8
+ from clearskies.exceptions.not_found import NotFound
9
+
10
+ __all__ = [
11
+ "Authentication",
12
+ "Authorization",
13
+ "ClientError",
14
+ "InputErrors",
15
+ "MissingDependency",
16
+ "MovedPermanently",
17
+ "MovedTemporarily",
18
+ "NotFound",
19
+ ]
@@ -0,0 +1,2 @@
1
+ class Authentication(Exception):
2
+ pass
@@ -0,0 +1,2 @@
1
+ class Authorization(Exception):
2
+ pass
@@ -0,0 +1,2 @@
1
+ class ClientError(Exception):
2
+ pass
@@ -0,0 +1,4 @@
1
+ class InputErrors(Exception):
2
+ def __init__(self, errors):
3
+ super().__init__(self, "Input Error")
4
+ self.errors = errors
@@ -0,0 +1,2 @@
1
+ class MissingDependency(Exception):
2
+ pass
@@ -0,0 +1,3 @@
1
+ class MovedPermanently(Exception):
2
+ def __init__(self, location):
3
+ super().__init__(self, location)
@@ -0,0 +1,3 @@
1
+ class MovedTemporarily(Exception):
2
+ def __init__(self, location):
3
+ super().__init__(self, location)
@@ -0,0 +1,2 @@
1
+ class NotFound(Exception):
2
+ pass
@@ -0,0 +1,7 @@
1
+ from . import routing, string, validations
2
+
3
+ __all__ = [
4
+ "routing",
5
+ "string",
6
+ "validations",
7
+ ]
@@ -0,0 +1,47 @@
1
+ from typing import Any, cast
2
+
3
+
4
+ def get_nested_attribute(data: dict[str, Any] | str, attr_path: str) -> Any:
5
+ """
6
+ Extract a nested attribute from JSON data using dot notation.
7
+
8
+ This function navigates through a nested JSON structure using a dot-separated path
9
+ to retrieve a specific attribute. If the input is a string, it will attempt to parse
10
+ it as JSON first.
11
+
12
+ Example:
13
+ ```
14
+ data = {"database": {"credentials": {"username": "admin", "password": "secret"}}}
15
+ username = get_nested_attribute(data, "database.credentials.username")
16
+ # Returns "admin"
17
+ ```
18
+
19
+ Args:
20
+ data: The JSON data as a dictionary or a JSON string
21
+ attr_path: The path to the attribute using dot notation (e.g., "database.username")
22
+
23
+ Returns:
24
+ The value at the specified path
25
+
26
+ Raises:
27
+ ValueError: If the data cannot be parsed as JSON
28
+ KeyError: If the attribute path doesn't exist in the data
29
+ """
30
+ keys = attr_path.split(".", 1)
31
+ if not isinstance(data, dict):
32
+ try:
33
+ import json
34
+
35
+ data = json.loads(data)
36
+ except Exception:
37
+ raise ValueError(f"Could not parse data as JSON to get attribute '{attr_path}'")
38
+
39
+ # At this point, we know data is a dictionary
40
+ data_dict = cast(dict[str, Any], data) # Help type checker understand data is a dict
41
+
42
+ if len(keys) == 1:
43
+ if keys[0] not in data_dict:
44
+ raise KeyError(f"Data does not contain attribute '{attr_path}'")
45
+ return data_dict[keys[0]]
46
+
47
+ return get_nested_attribute(data_dict[keys[0]], keys[1])