clear-skies 2.0.5__py3-none-any.whl → 2.0.7__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 (252) hide show
  1. {clear_skies-2.0.5.dist-info → clear_skies-2.0.7.dist-info}/METADATA +1 -1
  2. clear_skies-2.0.7.dist-info/RECORD +251 -0
  3. clearskies/__init__.py +61 -0
  4. clearskies/action.py +7 -0
  5. clearskies/authentication/__init__.py +15 -0
  6. clearskies/authentication/authentication.py +46 -0
  7. clearskies/authentication/authorization.py +16 -0
  8. clearskies/authentication/authorization_pass_through.py +20 -0
  9. clearskies/authentication/jwks.py +163 -0
  10. clearskies/authentication/public.py +5 -0
  11. clearskies/authentication/secret_bearer.py +553 -0
  12. clearskies/autodoc/__init__.py +8 -0
  13. clearskies/autodoc/formats/__init__.py +5 -0
  14. clearskies/autodoc/formats/oai3_json/__init__.py +7 -0
  15. clearskies/autodoc/formats/oai3_json/oai3_json.py +87 -0
  16. clearskies/autodoc/formats/oai3_json/oai3_schema_resolver.py +15 -0
  17. clearskies/autodoc/formats/oai3_json/parameter.py +35 -0
  18. clearskies/autodoc/formats/oai3_json/request.py +68 -0
  19. clearskies/autodoc/formats/oai3_json/response.py +28 -0
  20. clearskies/autodoc/formats/oai3_json/schema/__init__.py +11 -0
  21. clearskies/autodoc/formats/oai3_json/schema/array.py +9 -0
  22. clearskies/autodoc/formats/oai3_json/schema/default.py +13 -0
  23. clearskies/autodoc/formats/oai3_json/schema/enum.py +7 -0
  24. clearskies/autodoc/formats/oai3_json/schema/object.py +35 -0
  25. clearskies/autodoc/formats/oai3_json/test.json +1985 -0
  26. clearskies/autodoc/py.typed +0 -0
  27. clearskies/autodoc/request/__init__.py +15 -0
  28. clearskies/autodoc/request/header.py +6 -0
  29. clearskies/autodoc/request/json_body.py +6 -0
  30. clearskies/autodoc/request/parameter.py +8 -0
  31. clearskies/autodoc/request/request.py +47 -0
  32. clearskies/autodoc/request/url_parameter.py +6 -0
  33. clearskies/autodoc/request/url_path.py +6 -0
  34. clearskies/autodoc/response/__init__.py +5 -0
  35. clearskies/autodoc/response/response.py +9 -0
  36. clearskies/autodoc/schema/__init__.py +31 -0
  37. clearskies/autodoc/schema/array.py +10 -0
  38. clearskies/autodoc/schema/base64.py +8 -0
  39. clearskies/autodoc/schema/boolean.py +5 -0
  40. clearskies/autodoc/schema/date.py +5 -0
  41. clearskies/autodoc/schema/datetime.py +5 -0
  42. clearskies/autodoc/schema/double.py +5 -0
  43. clearskies/autodoc/schema/enum.py +17 -0
  44. clearskies/autodoc/schema/integer.py +6 -0
  45. clearskies/autodoc/schema/long.py +5 -0
  46. clearskies/autodoc/schema/number.py +6 -0
  47. clearskies/autodoc/schema/object.py +13 -0
  48. clearskies/autodoc/schema/password.py +5 -0
  49. clearskies/autodoc/schema/schema.py +11 -0
  50. clearskies/autodoc/schema/string.py +5 -0
  51. clearskies/backends/__init__.py +65 -0
  52. clearskies/backends/api_backend.py +1178 -0
  53. clearskies/backends/backend.py +136 -0
  54. clearskies/backends/cursor_backend.py +335 -0
  55. clearskies/backends/memory_backend.py +797 -0
  56. clearskies/backends/secrets_backend.py +106 -0
  57. clearskies/column.py +1233 -0
  58. clearskies/columns/__init__.py +71 -0
  59. clearskies/columns/audit.py +206 -0
  60. clearskies/columns/belongs_to_id.py +483 -0
  61. clearskies/columns/belongs_to_model.py +132 -0
  62. clearskies/columns/belongs_to_self.py +105 -0
  63. clearskies/columns/boolean.py +113 -0
  64. clearskies/columns/category_tree.py +275 -0
  65. clearskies/columns/category_tree_ancestors.py +51 -0
  66. clearskies/columns/category_tree_children.py +127 -0
  67. clearskies/columns/category_tree_descendants.py +48 -0
  68. clearskies/columns/created.py +95 -0
  69. clearskies/columns/created_by_authorization_data.py +116 -0
  70. clearskies/columns/created_by_header.py +99 -0
  71. clearskies/columns/created_by_ip.py +92 -0
  72. clearskies/columns/created_by_routing_data.py +97 -0
  73. clearskies/columns/created_by_user_agent.py +92 -0
  74. clearskies/columns/date.py +234 -0
  75. clearskies/columns/datetime.py +282 -0
  76. clearskies/columns/email.py +76 -0
  77. clearskies/columns/float.py +153 -0
  78. clearskies/columns/has_many.py +505 -0
  79. clearskies/columns/has_many_self.py +56 -0
  80. clearskies/columns/has_one.py +14 -0
  81. clearskies/columns/integer.py +160 -0
  82. clearskies/columns/json.py +128 -0
  83. clearskies/columns/many_to_many_ids.py +337 -0
  84. clearskies/columns/many_to_many_ids_with_data.py +274 -0
  85. clearskies/columns/many_to_many_models.py +158 -0
  86. clearskies/columns/many_to_many_pivots.py +134 -0
  87. clearskies/columns/phone.py +159 -0
  88. clearskies/columns/select.py +92 -0
  89. clearskies/columns/string.py +102 -0
  90. clearskies/columns/timestamp.py +164 -0
  91. clearskies/columns/updated.py +110 -0
  92. clearskies/columns/uuid.py +86 -0
  93. clearskies/configs/README.md +105 -0
  94. clearskies/configs/__init__.py +162 -0
  95. clearskies/configs/actions.py +43 -0
  96. clearskies/configs/any.py +13 -0
  97. clearskies/configs/any_dict.py +22 -0
  98. clearskies/configs/any_dict_or_callable.py +23 -0
  99. clearskies/configs/authentication.py +23 -0
  100. clearskies/configs/authorization.py +23 -0
  101. clearskies/configs/boolean.py +16 -0
  102. clearskies/configs/boolean_or_callable.py +18 -0
  103. clearskies/configs/callable_config.py +18 -0
  104. clearskies/configs/columns.py +34 -0
  105. clearskies/configs/conditions.py +30 -0
  106. clearskies/configs/config.py +24 -0
  107. clearskies/configs/datetime.py +18 -0
  108. clearskies/configs/datetime_or_callable.py +19 -0
  109. clearskies/configs/endpoint.py +23 -0
  110. clearskies/configs/endpoint_list.py +29 -0
  111. clearskies/configs/float.py +16 -0
  112. clearskies/configs/float_or_callable.py +18 -0
  113. clearskies/configs/integer.py +16 -0
  114. clearskies/configs/integer_or_callable.py +18 -0
  115. clearskies/configs/joins.py +30 -0
  116. clearskies/configs/list_any_dict.py +30 -0
  117. clearskies/configs/list_any_dict_or_callable.py +31 -0
  118. clearskies/configs/model_class.py +35 -0
  119. clearskies/configs/model_column.py +65 -0
  120. clearskies/configs/model_columns.py +56 -0
  121. clearskies/configs/model_destination_name.py +25 -0
  122. clearskies/configs/model_to_id_column.py +43 -0
  123. clearskies/configs/readable_model_column.py +9 -0
  124. clearskies/configs/readable_model_columns.py +9 -0
  125. clearskies/configs/schema.py +23 -0
  126. clearskies/configs/searchable_model_columns.py +9 -0
  127. clearskies/configs/security_headers.py +39 -0
  128. clearskies/configs/select.py +26 -0
  129. clearskies/configs/select_list.py +47 -0
  130. clearskies/configs/string.py +29 -0
  131. clearskies/configs/string_dict.py +32 -0
  132. clearskies/configs/string_list.py +32 -0
  133. clearskies/configs/string_list_or_callable.py +35 -0
  134. clearskies/configs/string_or_callable.py +18 -0
  135. clearskies/configs/timedelta.py +18 -0
  136. clearskies/configs/timezone.py +18 -0
  137. clearskies/configs/url.py +23 -0
  138. clearskies/configs/validators.py +45 -0
  139. clearskies/configs/writeable_model_column.py +9 -0
  140. clearskies/configs/writeable_model_columns.py +9 -0
  141. clearskies/configurable.py +76 -0
  142. clearskies/contexts/__init__.py +11 -0
  143. clearskies/contexts/cli.py +117 -0
  144. clearskies/contexts/context.py +98 -0
  145. clearskies/contexts/wsgi.py +76 -0
  146. clearskies/contexts/wsgi_ref.py +82 -0
  147. clearskies/decorators.py +33 -0
  148. clearskies/di/__init__.py +15 -0
  149. clearskies/di/additional_config.py +130 -0
  150. clearskies/di/additional_config_auto_import.py +17 -0
  151. clearskies/di/di.py +973 -0
  152. clearskies/di/inject/__init__.py +23 -0
  153. clearskies/di/inject/by_class.py +21 -0
  154. clearskies/di/inject/by_name.py +18 -0
  155. clearskies/di/inject/di.py +13 -0
  156. clearskies/di/inject/environment.py +14 -0
  157. clearskies/di/inject/input_output.py +20 -0
  158. clearskies/di/inject/now.py +13 -0
  159. clearskies/di/inject/requests.py +13 -0
  160. clearskies/di/inject/secrets.py +14 -0
  161. clearskies/di/inject/utcnow.py +13 -0
  162. clearskies/di/inject/uuid.py +15 -0
  163. clearskies/di/injectable.py +29 -0
  164. clearskies/di/injectable_properties.py +131 -0
  165. clearskies/di/test_module/__init__.py +6 -0
  166. clearskies/di/test_module/another_module/__init__.py +2 -0
  167. clearskies/di/test_module/module_class.py +5 -0
  168. clearskies/end.py +183 -0
  169. clearskies/endpoint.py +1314 -0
  170. clearskies/endpoint_group.py +336 -0
  171. clearskies/endpoints/__init__.py +25 -0
  172. clearskies/endpoints/advanced_search.py +526 -0
  173. clearskies/endpoints/callable.py +388 -0
  174. clearskies/endpoints/create.py +205 -0
  175. clearskies/endpoints/delete.py +139 -0
  176. clearskies/endpoints/get.py +271 -0
  177. clearskies/endpoints/health_check.py +183 -0
  178. clearskies/endpoints/list.py +574 -0
  179. clearskies/endpoints/restful_api.py +427 -0
  180. clearskies/endpoints/schema.py +189 -0
  181. clearskies/endpoints/simple_search.py +286 -0
  182. clearskies/endpoints/update.py +193 -0
  183. clearskies/environment.py +104 -0
  184. clearskies/exceptions/__init__.py +19 -0
  185. clearskies/exceptions/authentication.py +2 -0
  186. clearskies/exceptions/authorization.py +2 -0
  187. clearskies/exceptions/client_error.py +2 -0
  188. clearskies/exceptions/input_errors.py +4 -0
  189. clearskies/exceptions/missing_dependency.py +2 -0
  190. clearskies/exceptions/moved_permanently.py +3 -0
  191. clearskies/exceptions/moved_temporarily.py +3 -0
  192. clearskies/exceptions/not_found.py +2 -0
  193. clearskies/functional/__init__.py +7 -0
  194. clearskies/functional/routing.py +92 -0
  195. clearskies/functional/string.py +112 -0
  196. clearskies/functional/validations.py +76 -0
  197. clearskies/input_outputs/__init__.py +13 -0
  198. clearskies/input_outputs/cli.py +171 -0
  199. clearskies/input_outputs/exceptions/__init__.py +2 -0
  200. clearskies/input_outputs/exceptions/cli_input_error.py +2 -0
  201. clearskies/input_outputs/exceptions/cli_not_found.py +2 -0
  202. clearskies/input_outputs/headers.py +45 -0
  203. clearskies/input_outputs/input_output.py +138 -0
  204. clearskies/input_outputs/programmatic.py +69 -0
  205. clearskies/input_outputs/py.typed +0 -0
  206. clearskies/input_outputs/wsgi.py +77 -0
  207. clearskies/model.py +1922 -0
  208. clearskies/py.typed +0 -0
  209. clearskies/query/__init__.py +12 -0
  210. clearskies/query/condition.py +223 -0
  211. clearskies/query/join.py +136 -0
  212. clearskies/query/query.py +196 -0
  213. clearskies/query/sort.py +27 -0
  214. clearskies/schema.py +82 -0
  215. clearskies/secrets/__init__.py +6 -0
  216. clearskies/secrets/additional_configs/__init__.py +32 -0
  217. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +61 -0
  218. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +160 -0
  219. clearskies/secrets/akeyless.py +182 -0
  220. clearskies/secrets/exceptions/__init__.py +1 -0
  221. clearskies/secrets/exceptions/not_found.py +2 -0
  222. clearskies/secrets/secrets.py +38 -0
  223. clearskies/security_header.py +15 -0
  224. clearskies/security_headers/__init__.py +11 -0
  225. clearskies/security_headers/cache_control.py +67 -0
  226. clearskies/security_headers/cors.py +50 -0
  227. clearskies/security_headers/csp.py +94 -0
  228. clearskies/security_headers/hsts.py +22 -0
  229. clearskies/security_headers/x_content_type_options.py +0 -0
  230. clearskies/security_headers/x_frame_options.py +0 -0
  231. clearskies/test_base.py +8 -0
  232. clearskies/typing.py +11 -0
  233. clearskies/validator.py +37 -0
  234. clearskies/validators/__init__.py +33 -0
  235. clearskies/validators/after_column.py +62 -0
  236. clearskies/validators/before_column.py +13 -0
  237. clearskies/validators/in_the_future.py +32 -0
  238. clearskies/validators/in_the_future_at_least.py +11 -0
  239. clearskies/validators/in_the_future_at_most.py +10 -0
  240. clearskies/validators/in_the_past.py +32 -0
  241. clearskies/validators/in_the_past_at_least.py +10 -0
  242. clearskies/validators/in_the_past_at_most.py +10 -0
  243. clearskies/validators/maximum_length.py +26 -0
  244. clearskies/validators/maximum_value.py +29 -0
  245. clearskies/validators/minimum_length.py +26 -0
  246. clearskies/validators/minimum_value.py +29 -0
  247. clearskies/validators/required.py +34 -0
  248. clearskies/validators/timedelta.py +59 -0
  249. clearskies/validators/unique.py +30 -0
  250. clear_skies-2.0.5.dist-info/RECORD +0 -4
  251. {clear_skies-2.0.5.dist-info → clear_skies-2.0.7.dist-info}/WHEEL +0 -0
  252. {clear_skies-2.0.5.dist-info → clear_skies-2.0.7.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,574 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections import OrderedDict
5
+ from typing import TYPE_CHECKING, Any, Callable
6
+
7
+ import clearskies.configs
8
+ import clearskies.exceptions
9
+ from clearskies import authentication, autodoc, typing
10
+ from clearskies.endpoint import Endpoint
11
+ from clearskies.functional import string
12
+ from clearskies.input_outputs import InputOutput
13
+
14
+ if TYPE_CHECKING:
15
+ from clearskies import Schema, SecurityHeader
16
+ from clearskies.column import Column
17
+ from clearskies.model import Model
18
+
19
+
20
+ class List(Endpoint):
21
+ """
22
+ Create a list endpoint that fetches and returns records to the end client.
23
+
24
+ A list endpoint has four required parameters:
25
+
26
+ | Name | Value |
27
+ |----------------------------|---------------------------------------------------------------------------------------|
28
+ | `model_class` | The model class for the endpoint to use to find and return records. |
29
+ | `readable_column_names` | A list of columns from the model class that the endpoint should return to the client. |
30
+ | `sortable_column_names` | A list of columns that the client is allowed to sort by. |
31
+ | `default_sort_column_name` | The default column to sort by. |
32
+
33
+ Here's a basic working example:
34
+
35
+ ```python
36
+ import clearskies
37
+
38
+ class User(clearskies.Model):
39
+ id_column_name = "id"
40
+ backend = clearskies.backends.MemoryBackend()
41
+ id = clearskies.columns.Uuid()
42
+ name = clearskies.columns.String()
43
+
44
+
45
+ list_users = clearskies.endpoints.List(
46
+ model_class=User,
47
+ readable_column_names=["id", "name"],
48
+ sortable_column_names=["id", "name"],
49
+ default_sort_column_name="name",
50
+ )
51
+
52
+ wsgi = clearskies.contexts.WsgiRef(
53
+ list_users,
54
+ classes=[User],
55
+ bindings={
56
+ "memory_backend_default_data": [
57
+ {
58
+ "model_class": User,
59
+ "records": [
60
+ {"id": "1-2-3-4", "name": "Bob"},
61
+ {"id": "1-2-3-5", "name": "Jane"},
62
+ {"id": "1-2-3-6", "name": "Greg"},
63
+ ],
64
+ },
65
+ ]
66
+ },
67
+ )
68
+ wsgi()
69
+ ```
70
+
71
+ You can then fetch your records:
72
+
73
+ ```bash
74
+ $ curl 'http://localhost:8080/' | jq
75
+ {
76
+ "status": "success",
77
+ "error": "",
78
+ "data": [
79
+ {"id": "1-2-3-4", "name": "Bob"},
80
+ {"id": "1-2-3-6", "name": "Greg"},
81
+ {"id": "1-2-3-5", "name": "Jane"},
82
+ ],
83
+ "pagination": {
84
+ "number_results": 3,
85
+ "limit": 50,
86
+ "next_page": {}
87
+ },
88
+ "input_errors": {}
89
+ }
90
+ ```
91
+
92
+ Pagination can be set via query parameters or the JSON body:
93
+
94
+ ```bash
95
+ $ curl 'http://localhost:8080/?sort=name&direction=desc&limit=2' | jq
96
+ {
97
+ "status": "success",
98
+ "error": "",
99
+ "data": [
100
+ {"id": "1-2-3-5", "name": "Jane"},
101
+ {"id": "1-2-3-6", "name": "Greg"},
102
+ ],
103
+ "pagination": {
104
+ "number_results": 3,
105
+ "limit": 2,
106
+ "next_page": {"start": 2}
107
+ },
108
+ "input_errors": {}
109
+ }
110
+ ```
111
+
112
+ In the response, '.pagination.next_page` is a dictionary that returns the query parameters to set in order to fetch the next page of results.
113
+ Note that the pagination method depends on the backend. The memory backend supports pagination via start/limit, while other backends may
114
+ support alternate pagination schemes. Clearskies automatically handles the difference, so it's important to use `.pagination.next_page` to fetch
115
+ the next page of results.
116
+
117
+ Use `where`, `joins`, and `group_by` to automatically adjust the query used by the list endpoint. In particular, where is a list of either
118
+ conditions (as a string) or a callable that can modify the query directly via the model class. For example:
119
+
120
+ ```python
121
+ list_users = clearskies.endpoints.List(
122
+ model_class=User,
123
+ readable_column_names=["id", "name"],
124
+ sortable_column_names=["id", "name"],
125
+ default_sort_column_name="name",
126
+ where=[User.name.equals("Jane")], # equivalent: where=["name=Jane"]
127
+ )
128
+ ```
129
+
130
+ With the above definition, the list endpoint will only ever return records with a name of "Jane". The following uses standard dependency
131
+ injection rules to execute a similar filter based on arbitrary logic required:
132
+
133
+ ```python
134
+ import datetime
135
+
136
+ list_users = clearskies.endpoints.List(
137
+ model_class=User,
138
+ readable_column_names=["id", "name"],
139
+ sortable_column_names=["id", "name"],
140
+ default_sort_column_name="name",
141
+ where=[
142
+ lambda model, now: model.where("name=Jane")
143
+ if now > datetime.datetime(2025, 1, 1)
144
+ else model
145
+ ],
146
+ )
147
+ ```
148
+
149
+ As shown in the above example, a function called in this way can request additional dependencies as needed, per the standard dependency rules.
150
+ The function needs to return the adjusted model object, which is usually as simple as returning the result of `model.where(?)`. While the
151
+ above example uses a lambda function, of course you can attach any other kind of callable - a function, a method of a class, etc...
152
+ """
153
+
154
+ """
155
+ The default column to sort by.
156
+ """
157
+ default_sort_column_name = clearskies.configs.ModelColumn("model_class")
158
+
159
+ """
160
+ The default sort direction (ASC or DESC).
161
+ """
162
+ default_sort_direction = clearskies.configs.Select(["ASC", "DESC"], default="ASC")
163
+
164
+ """
165
+ The number of records returned if the client doesn't specify a different number of records (default: 50).
166
+ """
167
+ default_limit = clearskies.configs.Integer(default=50)
168
+
169
+ """
170
+ The maximum number of records the client is allowed to request (0 == no limit)
171
+ """
172
+ maximum_limit = clearskies.configs.Integer(default=200)
173
+
174
+ """
175
+ A column to group by.
176
+ """
177
+ group_by_column_name = clearskies.configs.ModelColumn("model_class")
178
+
179
+ readable_column_names = clearskies.configs.ReadableModelColumns("model_class")
180
+ sortable_column_names = clearskies.configs.ReadableModelColumns("model_class", allow_relationship_references=True)
181
+ searchable_column_names = clearskies.configs.SearchableModelColumns(
182
+ "model_class", allow_relationship_references=True
183
+ )
184
+
185
+ @clearskies.decorators.parameters_to_properties
186
+ def __init__(
187
+ self,
188
+ model_class: type[Model],
189
+ readable_column_names: list[str],
190
+ sortable_column_names: list[str],
191
+ default_sort_column_name: str | None,
192
+ default_sort_direction: str = "ASC",
193
+ default_limit: int = 50,
194
+ maximum_limit: int = 200,
195
+ where: typing.condition | list[typing.condition] = [],
196
+ joins: typing.join | list[typing.join] = [],
197
+ url: str = "",
198
+ request_methods: list[str] = ["GET"],
199
+ response_headers: list[str | Callable[..., list[str]]] = [],
200
+ output_map: Callable[..., dict[str, Any]] | None = None,
201
+ output_schema: Schema | None = None,
202
+ column_overrides: dict[str, Column] = {},
203
+ group_by_column_name: str = "",
204
+ internal_casing: str = "snake_case",
205
+ external_casing: str = "snake_case",
206
+ security_headers: list[SecurityHeader] = [],
207
+ description: str = "",
208
+ authentication: authentication.Authentication = authentication.Public(),
209
+ authorization: authentication.Authorization = authentication.Authorization(),
210
+ ):
211
+ # 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
212
+ # just stores them in parameters, which we have already done. However, the parent does do some extra initialization stuff that we need,
213
+ # which is why we have to call the parent.
214
+ super().__init__()
215
+
216
+ @property
217
+ def searchable_columns(self) -> dict[str, Column]:
218
+ if self._searchable_columns is None:
219
+ self._searchable_columns = {name: self.columns[name] for name in self.searchable_column_names}
220
+ return self._searchable_columns
221
+
222
+ @property
223
+ def sortable_columns(self) -> dict[str, Column]:
224
+ if self._sortable_columns is None:
225
+ self._sortable_columns = {name: self.columns[name] for name in self.sortable_column_names}
226
+ return self._sortable_columns
227
+
228
+ @property
229
+ def allowed_request_keys(self) -> list[str]:
230
+ return [*["sort", "direction", "limit"], *self.searchable_column_names]
231
+
232
+ @property
233
+ def internal_request_keys(self) -> list[str]:
234
+ return ["sort", "direction", "limit"]
235
+
236
+ def handle(self, input_output: InputOutput):
237
+ model = self.fetch_model_with_base_query(input_output)
238
+ if not input_output.request_data and input_output.has_body():
239
+ raise clearskies.exceptions.ClientError("Request body was not valid JSON")
240
+ if input_output.request_data and not isinstance(input_output.request_data, dict):
241
+ raise clearskies.exceptions.ClientError("When present, request body must be a JSON dictionary")
242
+ request_data = self.map_input_to_internal_names(input_output.request_data) # type: ignore
243
+ query_parameters = self.map_input_to_internal_names(input_output.query_parameters)
244
+ pagination_data = {}
245
+ for key in model.allowed_pagination_keys():
246
+ if key in request_data and key in query_parameters:
247
+ original_name = self.auto_case_internal_column_name(key)
248
+ raise clearskies.exceptions.ClientError(
249
+ f"Ambiguous request: key '{original_name}' is present in both the JSON body and URL data"
250
+ )
251
+ if key in request_data:
252
+ pagination_data[key] = request_data[key]
253
+ del request_data[key]
254
+ if key in query_parameters:
255
+ pagination_data[key] = query_parameters[key]
256
+ del query_parameters[key]
257
+ if request_data or query_parameters or pagination_data:
258
+ self.check_request_data(request_data, query_parameters, pagination_data)
259
+ model = self.configure_model_from_request_data(model, request_data, query_parameters, pagination_data)
260
+ if not model.get_query().limit:
261
+ model = model.limit(self.default_limit)
262
+ if not model.get_query().sorts and self.default_sort_column_name:
263
+ model = model.sort_by(
264
+ self.default_sort_column_name,
265
+ self.default_sort_direction,
266
+ model.destination_name(),
267
+ )
268
+ if self.group_by_column_name:
269
+ model = model.group_by(self.group_by_column_name)
270
+
271
+ return self.success(
272
+ input_output,
273
+ [self.model_as_json(record, input_output) for record in model],
274
+ number_results=len(model) if model.backend.can_count else None,
275
+ limit=model.get_query().limit,
276
+ next_page=model.next_page_data(),
277
+ )
278
+
279
+ def configure_model_from_request_data(
280
+ self,
281
+ model: Model,
282
+ request_data: dict[str, Any],
283
+ query_parameters: dict[str, Any],
284
+ pagination_data: dict[str, Any],
285
+ ) -> Model:
286
+ limit = int(self.from_either(request_data, query_parameters, "limit", default=self.default_limit))
287
+ model = model.limit(limit)
288
+ if pagination_data:
289
+ model = model.pagination(**pagination_data)
290
+ sort = self.from_either(request_data, query_parameters, "sort")
291
+ direction = self.from_either(request_data, query_parameters, "direction")
292
+ if sort and direction:
293
+ model = self.add_join(sort, model)
294
+ [sort_column, sort_table] = self.resolve_references_for_query(sort)
295
+ model = model.sort_by(sort_column, direction, sort_table) # type: ignore
296
+
297
+ return model
298
+
299
+ def map_input_to_internal_names(self, data: dict[str, Any]) -> dict[str, Any]:
300
+ if not data:
301
+ return {}
302
+ internal_request_keys = [*self.internal_request_keys, *self.model.allowed_pagination_keys()]
303
+ for key in internal_request_keys:
304
+ mapped_key = self.auto_case_internal_column_name(key)
305
+ if mapped_key != key and mapped_key in data:
306
+ data[key] = data[mapped_key]
307
+ del data[mapped_key]
308
+ # any non-internal fields are assumed to be column names and need to go
309
+ # through the full mapping
310
+ for key in set(self.allowed_request_keys) - set(internal_request_keys):
311
+ mapped_key = self.auto_case_column_name(key, True)
312
+ if mapped_key != key and mapped_key in data:
313
+ data[key] = data[mapped_key]
314
+ del data[mapped_key]
315
+
316
+ # finally, if we have a sort key set then convert the value to the properly cased column name
317
+ if "sort" in data:
318
+ # we can't just take the sort value and convert it to internal casing because camel/title case
319
+ # to snake_case can be ambiguous (while snake_case to camel/title is not)
320
+ sort_column_map = {}
321
+ for internal_name in self.sortable_column_names:
322
+ external_name = self.auto_case_column_name(internal_name, True)
323
+ sort_column_map[external_name] = internal_name
324
+ # sometimes the sort may be a list of directives
325
+ if isinstance(data["sort"], list):
326
+ for index, sort_entry in enumerate(data["sort"]):
327
+ if "column" not in sort_entry:
328
+ continue
329
+ if sort_entry["column"] in sort_column_map:
330
+ sort_entry["column"] = sort_column_map[sort_entry["column"]]
331
+ else:
332
+ if data["sort"] in sort_column_map:
333
+ data["sort"] = sort_column_map[data["sort"]]
334
+
335
+ return data
336
+
337
+ def check_request_data(
338
+ self, request_data: dict[str, Any], query_parameters: dict[str, Any], pagination_data: dict[str, Any]
339
+ ) -> None:
340
+ if pagination_data:
341
+ error = self.model.validate_pagination_data(pagination_data, self.auto_case_internal_column_name)
342
+ if error:
343
+ raise clearskies.exceptions.ClientError(error)
344
+ for key in request_data.keys():
345
+ if key not in self.allowed_request_keys:
346
+ raise clearskies.exceptions.ClientError(f"Invalid request parameter found in request body: '{key}'")
347
+ for key in query_parameters.keys():
348
+ if key not in self.allowed_request_keys:
349
+ raise clearskies.exceptions.ClientError(f"Invalid request parameter found in URL data: '{key}'")
350
+ if key in request_data:
351
+ raise clearskies.exceptions.ClientError(
352
+ f"Ambiguous request: '{key}' was found in both the request body and URL data"
353
+ )
354
+ self.validate_limit(request_data, query_parameters)
355
+ sort = self.from_either(request_data, query_parameters, "sort")
356
+ direction = self.from_either(request_data, query_parameters, "direction")
357
+ if sort and type(sort) != str:
358
+ raise clearskies.exceptions.ClientError("Invalid request: 'sort' should be a string")
359
+ if direction and type(direction) != str:
360
+ raise clearskies.exceptions.ClientError("Invalid request: 'direction' should be a string")
361
+ if sort or direction:
362
+ if (sort and not direction) or (direction and not sort):
363
+ raise clearskies.exceptions.ClientError(
364
+ "You must specify 'sort' and 'direction' together in the request - not just one of them"
365
+ )
366
+ if sort not in self.sortable_column_names:
367
+ raise clearskies.exceptions.ClientError(f"Invalid request: invalid sort column")
368
+ if direction.lower() not in ["asc", "desc"]:
369
+ raise clearskies.exceptions.ClientError("Invalid request: direction must be 'asc' or 'desc'")
370
+ self.check_search_in_request_data(request_data, query_parameters)
371
+
372
+ def validate_limit(self, request_data: dict[str, Any], query_parameters: dict[str, Any]) -> None:
373
+ limit = self.from_either(request_data, query_parameters, "limit")
374
+ if limit is not None and type(limit) != int and type(limit) != float and type(limit) != str:
375
+ raise clearskies.exceptions.ClientError("Invalid request: 'limit' should be an integer")
376
+ if limit:
377
+ try:
378
+ limit = int(limit)
379
+ except ValueError:
380
+ raise clearskies.exceptions.ClientError("Invalid request: 'limit' should be an integer")
381
+ if limit:
382
+ if limit > self.maximum_limit:
383
+ raise clearskies.exceptions.ClientError(f"Invalid request: 'limit' must be at most {self.max_limit}")
384
+ if limit < 0:
385
+ raise clearskies.exceptions.ClientError(f"Invalid request: 'limit' must be positive")
386
+
387
+ def check_search_in_request_data(self, request_data: dict[str, Any], query_parameters: dict[str, Any]):
388
+ return None
389
+
390
+ def unpack_column_name_with_relationship(self, column_name: str) -> list[str]:
391
+ if "." not in column_name:
392
+ return ["", column_name]
393
+ return column_name.split(".", 1)
394
+
395
+ def resolve_references_for_query(self, column_name: str) -> list[str | None]:
396
+ """
397
+ Take the column name and returns the name and table.
398
+
399
+ If it's just a column name, we assume the table is the table for our model class.
400
+ If it's something like `belongs_to_column.column_name`, then it will find the appropriate
401
+ table reference.
402
+ """
403
+ if not column_name:
404
+ return [None, None]
405
+ [relationship_column_name, column_name] = self.unpack_column_name_with_relationship(column_name)
406
+ if not relationship_column_name:
407
+ return [self.model.destination_name(), column_name]
408
+
409
+ return [self.columns[relationship_column_name].join_table_alias(), column_name]
410
+
411
+ def add_join(self, column_name: str, model: Model) -> Model:
412
+ """
413
+ Add a join to the query for the given column name in the case where it references a column in a belongs to.
414
+
415
+ If column_name is something like `belongs_to_column.column_name`, this will add have the belongs to column
416
+ add it's typical join condition, so that further sorting/searching can work.
417
+
418
+ If column_name is empty, or doesn't contain a period, then this does nothing.
419
+ """
420
+ if not column_name:
421
+ return model
422
+ [relationship_column_name, column_name] = self.unpack_column_name_with_relationship(column_name)
423
+ if not relationship_column_name:
424
+ return model
425
+ return self.columns[relationship_column_name].add_join(model)
426
+
427
+ def from_either(self, request_data, query_parameters, key, default=None, ignore_none=True):
428
+ """Return the key from either object. Assumes it is not present in both."""
429
+ if key in request_data:
430
+ if request_data[key] is not None or not ignore_none:
431
+ return request_data[key]
432
+ if key in query_parameters:
433
+ if query_parameters[key] is not None or not ignore_none:
434
+ return query_parameters[key]
435
+ return default
436
+
437
+ def documentation(self) -> list[autodoc.request.Request]:
438
+ nice_model = string.camel_case_to_words(self.model_class.__name__)
439
+ schema_model_name = string.camel_case_to_snake_case(self.model_class.__name__)
440
+ data_schema = self.documentation_data_schema()
441
+
442
+ authentication = self.authentication
443
+ standard_error_responses = []
444
+ if not getattr(authentication, "is_public", False):
445
+ standard_error_responses.append(self.documentation_access_denied_response())
446
+ if getattr(authentication, "can_authorize", False):
447
+ standard_error_responses.append(self.documentation_unauthorized_response())
448
+
449
+ return [
450
+ autodoc.request.Request(
451
+ f"Fetch the list of current {nice_model} records",
452
+ [
453
+ self.documentation_success_response(
454
+ autodoc.schema.Array(
455
+ self.auto_case_internal_column_name("data"),
456
+ autodoc.schema.Object(nice_model, children=data_schema, model_name=schema_model_name),
457
+ ),
458
+ description=f"The matching {nice_model} records",
459
+ include_pagination=True,
460
+ ),
461
+ *standard_error_responses,
462
+ self.documentation_generic_error_response(),
463
+ ],
464
+ relative_path=self.url,
465
+ request_methods=self.request_methods,
466
+ parameters=self.documentation_request_parameters(),
467
+ root_properties={
468
+ "security": self.documentation_request_security(),
469
+ },
470
+ ),
471
+ ]
472
+
473
+ def documentation_request_parameters(self) -> list[autodoc.request.Parameter]:
474
+ return [
475
+ *self.documentation_url_pagination_parameters(),
476
+ *self.documentation_url_sort_parameters(),
477
+ *self.documentation_url_search_parameters(),
478
+ *self.documentation_json_search_parameters(),
479
+ *self.documentation_url_parameters(),
480
+ ]
481
+
482
+ def documentation_models(self) -> dict[str, autodoc.schema.Schema]:
483
+ schema_model_name = string.camel_case_to_snake_case(self.model_class.__name__)
484
+
485
+ return {
486
+ schema_model_name: autodoc.schema.Object(
487
+ self.auto_case_internal_column_name("data"),
488
+ children=self.documentation_data_schema(),
489
+ ),
490
+ }
491
+
492
+ def documentation_url_pagination_parameters(self) -> list[autodoc.request.Parameter]:
493
+ url_parameters = [
494
+ autodoc.request.URLParameter(
495
+ autodoc.schema.Integer(self.auto_case_internal_column_name("limit")),
496
+ description="The number of records to return",
497
+ ),
498
+ ]
499
+
500
+ for parameter in self.model.documentation_pagination_parameters(self.auto_case_internal_column_name):
501
+ (schema, description) = parameter
502
+ url_parameters.append(autodoc.request.URLParameter(schema, description=description))
503
+
504
+ return url_parameters # type: ignore
505
+
506
+ def documentation_url_sort_parameters(self) -> list[autodoc.request.Parameter]:
507
+ sort_columns = [self.auto_case_column_name(internal_name, True) for internal_name in self.sortable_column_names]
508
+ directions = [self.auto_case_column_name(internal_name, True) for internal_name in ["asc", "desc"]]
509
+
510
+ return [
511
+ autodoc.request.URLParameter(
512
+ autodoc.schema.Enum(
513
+ self.auto_case_internal_column_name("sort"),
514
+ sort_columns,
515
+ autodoc.schema.String(self.auto_case_internal_column_name("sort")),
516
+ example=self.auto_case_column_name("name", True),
517
+ ),
518
+ description=f"Column to sort by",
519
+ ),
520
+ autodoc.request.URLParameter(
521
+ autodoc.schema.Enum(
522
+ self.auto_case_internal_column_name("direction"),
523
+ directions,
524
+ autodoc.schema.String(self.auto_case_internal_column_name("direction")),
525
+ example=self.auto_case_column_name("asc", True),
526
+ ),
527
+ description=f"Direction to sort",
528
+ ),
529
+ ]
530
+
531
+ def documentation_json_pagination_parameters(self) -> list[autodoc.request.Parameter]:
532
+ json_parameters = [
533
+ autodoc.request.JSONBody(
534
+ autodoc.schema.Integer(self.auto_case_internal_column_name("limit")),
535
+ description="The number of records to return",
536
+ ),
537
+ ]
538
+
539
+ for parameter in self.model.documentation_pagination_parameters(self.auto_case_internal_column_name):
540
+ (schema, description) = parameter
541
+ json_parameters.append(autodoc.request.JSONBody(schema, description=description))
542
+
543
+ return json_parameters # type: ignore
544
+
545
+ def documentation_json_sort_parameters(self) -> list[autodoc.request.Parameter]:
546
+ sort_columns = [self.auto_case_column_name(internal_name, True) for internal_name in self.sortable_column_names]
547
+ directions = [self.auto_case_column_name(internal_name, True) for internal_name in ["asc", "desc"]]
548
+
549
+ return [
550
+ autodoc.request.JSONBody(
551
+ autodoc.schema.Enum(
552
+ self.auto_case_internal_column_name("sort"),
553
+ sort_columns,
554
+ autodoc.schema.String(self.auto_case_internal_column_name("sort")),
555
+ example=self.auto_case_column_name("name", True),
556
+ ),
557
+ description=f"Column to sort by",
558
+ ),
559
+ autodoc.request.JSONBody(
560
+ autodoc.schema.Enum(
561
+ self.auto_case_internal_column_name("direction"),
562
+ directions,
563
+ autodoc.schema.String(self.auto_case_internal_column_name("direction")),
564
+ example=self.auto_case_column_name("asc", True),
565
+ ),
566
+ description=f"Direction to sort",
567
+ ),
568
+ ]
569
+
570
+ def documentation_url_search_parameters(self) -> list[autodoc.request.Parameter]:
571
+ return []
572
+
573
+ def documentation_json_search_parameters(self) -> list[autodoc.request.Parameter]:
574
+ return []