clear-skies 2.0.5__py3-none-any.whl → 2.0.6__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.6.dist-info}/METADATA +1 -1
  2. clear_skies-2.0.6.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 +14 -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.6.dist-info}/WHEEL +0 -0
  252. {clear_skies-2.0.5.dist-info → clear_skies-2.0.6.dist-info}/licenses/LICENSE +0 -0
clearskies/endpoint.py ADDED
@@ -0,0 +1,1314 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import urllib.parse
5
+ from collections import OrderedDict
6
+ from typing import TYPE_CHECKING, Any, Callable
7
+
8
+ import clearskies.column
9
+ import clearskies.configs
10
+ import clearskies.configurable
11
+ import clearskies.decorators
12
+ import clearskies.di
13
+ import clearskies.end
14
+ import clearskies.typing
15
+ from clearskies import autodoc, exceptions
16
+ from clearskies.authentication import Authentication, Authorization, Public
17
+ from clearskies.autodoc import schema
18
+ from clearskies.autodoc.request import Parameter, Request
19
+ from clearskies.autodoc.response import Response
20
+ from clearskies.functional import routing, string, validations
21
+
22
+ if TYPE_CHECKING:
23
+ from clearskies import Column, Model, SecurityHeader
24
+ from clearskies.input_outputs import InputOutput
25
+ from clearskies.schema import Schema
26
+ from clearskies.security_headers import Cors
27
+
28
+
29
+ class Endpoint(
30
+ clearskies.end.End, # type: ignore
31
+ clearskies.configurable.Configurable,
32
+ clearskies.di.InjectableProperties,
33
+ ):
34
+ """
35
+ Automating drudgery!
36
+
37
+ With clearskies, endpoints exist to offload some drudgery and make your life easier, but they can also
38
+ get out of your way when you don't need them. Think of them as pre-built endpoints that can execute
39
+ common functionality needed for web applications/APIs. Instead of defining a function that fetches
40
+ records from your backend and returns them to the end user, you can let the list endpoint do this for you
41
+ with a minimal amount of configuration. Instead of making an endpoint that creates records, just deploy
42
+ a create endpoint. While this gives clearskies some helpful capabiltiies for automation, it also has
43
+ the Callable endpoint which simply calls a developer-defined function, and therefore allows clearskies to
44
+ act like a much more typical framework.
45
+ """
46
+
47
+ """
48
+ The dependency injection container
49
+ """
50
+ di = clearskies.di.inject.Di()
51
+
52
+ """
53
+ Whether or not this endpoint can handle CORS
54
+ """
55
+ has_cors = False
56
+
57
+ """
58
+ The actual CORS header
59
+ """
60
+ cors_header: Cors | None = None
61
+
62
+ """
63
+ Set some response headers that should be returned for this endpoint.
64
+
65
+ Provide a list of response headers to return to the caller when this endpoint is executed.
66
+ This should be given a list containing a combination of strings or callables that return a list of strings.
67
+ The strings in question should be headers formatted as "key: value". If you attach a callable, it can accept
68
+ any of the standard dependencies or context-specific values like any other callable in a clearskies
69
+ application:
70
+
71
+ ```python
72
+ def custom_headers(query_parameters):
73
+ some_value = "yes" if query_parameters.get("stuff") else "no"
74
+ return [f"x-custom: {some_value}", "content-type: application/custom"]
75
+
76
+ endpoint = clearskies.endpoints.Callable(
77
+ lambda: {"hello": "world"},
78
+ response_headers=custom_headers,
79
+ )
80
+
81
+ wsgi = clearskies.contexts.WsgiRef(endpoint)
82
+ wsgi()
83
+ ```
84
+ """
85
+ response_headers = clearskies.configs.StringListOrCallable(default=[])
86
+
87
+ """
88
+ Set the URL for the endpoint
89
+
90
+ When an endpoint is attached directly to a context, then the endpoint's URL becomes the exact URL
91
+ to invoke the endpoint. If it is instead attached to an endpoint group, then the URL of the endpoint
92
+ becomes a suffix on the URL of the group. This is described in more detail in the documentation for endpoint
93
+ groups, so here's an example of attaching endpoints directly and setting the URL:
94
+
95
+ ```python
96
+ import clearskies
97
+
98
+ endpoint = clearskies.endpoints.Callable(
99
+ lambda: {"hello": "World"},
100
+ url="/hello/world",
101
+ )
102
+
103
+ wsgi = clearskies.contexts.WsgiRef(endpoint)
104
+ wsgi()
105
+ ```
106
+
107
+ Which then acts as expected:
108
+
109
+ ```bash
110
+ $ curl 'http://localhost:8080/hello/asdf' | jq
111
+ {
112
+ "status": "client_error",
113
+ "error": "Not Found",
114
+ "data": [],
115
+ "pagination": {},
116
+ "input_errors": {}
117
+ }
118
+
119
+ $ curl 'http://localhost:8080/hello/world' | jq
120
+ {
121
+ "status": "success",
122
+ "error": "",
123
+ "data": {
124
+ "hello": "world"
125
+ },
126
+ "pagination": {},
127
+ "input_errors": {}
128
+ }
129
+ ```
130
+
131
+ Some endpoints allow or require the use of named routing parameters. Named routing paths are created using either the
132
+ `/{name}/` syntax or `/:name/`. These parameters can be injected into any callable via the `routing_data`
133
+ dependency injection name, as well as via their name:
134
+
135
+ ```python
136
+ import clearskies
137
+
138
+ endpoint = clearskies.endpoints.Callable(
139
+ lambda first_name, last_name: {"hello": f"{first_name} {last_name}"},
140
+ url="/hello/:first_name/{last_name}",
141
+ )
142
+
143
+ wsgi = clearskies.contexts.WsgiRef(endpoint)
144
+ wsgi()
145
+ ```
146
+
147
+ Which you can then invoke in the usual way:
148
+
149
+ ```bash
150
+ $ curl 'http://localhost:8080/hello/bob/brown' | jq
151
+ {
152
+ "status": "success",
153
+ "error": "",
154
+ "data": {
155
+ "hello": "bob brown"
156
+ },
157
+ "pagination": {},
158
+ "input_errors": {}
159
+ }
160
+
161
+ ```
162
+
163
+ """
164
+ url = clearskies.configs.Url(default="")
165
+
166
+ """
167
+ The allowed request methods for this endpoint.
168
+
169
+ By default, only GET is allowed.
170
+
171
+ ```python
172
+ import clearskies
173
+
174
+ endpoint = clearskies.endpoints.Callable(
175
+ lambda: {"hello": "world"},
176
+ request_methods=["POST"],
177
+ )
178
+
179
+ wsgi = clearskies.contexts.WsgiRef(endpoint)
180
+ wsgi()
181
+ ```
182
+
183
+ And to execute:
184
+
185
+ ```bash
186
+ $ curl 'http://localhost:8080/' -X POST | jq
187
+ {
188
+ "status": "success",
189
+ "error": "",
190
+ "data": {
191
+ "hello": "world"
192
+ },
193
+ "pagination": {},
194
+ "input_errors": {}
195
+ }
196
+
197
+ $ curl 'http://localhost:8080/' -X GET | jq
198
+ {
199
+ "status": "client_error",
200
+ "error": "Not Found",
201
+ "data": [],
202
+ "pagination": {},
203
+ "input_errors": {}
204
+ }
205
+ ```
206
+ """
207
+ request_methods = clearskies.configs.SelectList(
208
+ allowed_values=["GET", "POST", "PUT", "DELETE", "PATCH", "QUERY"], default=["GET"]
209
+ )
210
+
211
+ """
212
+ The authentication for this endpoint (default is public)
213
+
214
+ Use this to attach an instance of `clearskies.authentication.Authentication` to an endpoint, which enforces authentication.
215
+ For more details, see the dedicated documentation section on authentication and authorization. By default, all endpoints are public.
216
+ """
217
+ authentication = clearskies.configs.Authentication(default=Public())
218
+
219
+ """
220
+ The authorization rules for this endpoint
221
+
222
+ Use this to attach an instance of `clearskies.authentication.Authorization` to an endpoint, which enforces authorization.
223
+ For more details, see the dedicated documentation section on authentication and authorization. By default, no authorization is enforced.
224
+ """
225
+ authorization = clearskies.configs.Authorization(default=Authorization())
226
+
227
+ """
228
+ An override of the default model-to-json mapping for endpoints that auto-convert models to json.
229
+
230
+ Many endpoints allow you to return a model which is then automatically converted into a JSON response. When this is the case,
231
+ you can provide a callable in the `output_map` parameter which will be called instead of following the usual method for
232
+ JSON conversion. Note that if you use this method, you should also specify `output_schema`, which the autodocumentation
233
+ will then use to document the endpoint.
234
+
235
+ Your function can request any named dependency injection parameter as well as the standard context parameters for the request.
236
+
237
+ ```python
238
+ import clearskies
239
+ import datetime
240
+ from dateutil.relativedelta import relativedelta
241
+
242
+ class User(clearskies.Model):
243
+ id_column_name = "id"
244
+ backend = clearskies.backends.MemoryBackend()
245
+ id = clearskies.columns.Uuid()
246
+ name = clearskies.columns.String()
247
+ dob = clearskies.columns.Datetime()
248
+
249
+ class UserResponse(clearskies.Schema):
250
+ id = clearskies.columns.String()
251
+ name = clearskies.columns.String()
252
+ age = clearskies.columns.Integer()
253
+ is_special = clearskies.columns.Boolean()
254
+
255
+ def user_to_json(model: User, utcnow: datetime.datetime, special_person: str):
256
+ return {
257
+ "id": model.id,
258
+ "name": model.name,
259
+ "age": relativedelta(utcnow, model.dob).years,
260
+ "is_special": model.name.lower() == special_person.lower(),
261
+ }
262
+
263
+ list_users = clearskies.endpoints.List(
264
+ model_class=User,
265
+ url="/{special_person}",
266
+ output_map = user_to_json,
267
+ output_schema = UserResponse,
268
+ readable_column_names=["id", "name"],
269
+ sortable_column_names=["id", "name", "dob"],
270
+ default_sort_column_name="dob",
271
+ default_sort_direction="DESC",
272
+ )
273
+
274
+ wsgi = clearskies.contexts.WsgiRef(
275
+ list_users,
276
+ classes=[User],
277
+ bindings={
278
+ "special_person": "jane",
279
+ "memory_backend_default_data": [
280
+ {
281
+ "model_class": User,
282
+ "records": [
283
+ {"id": "1-2-3-4", "name": "Bob", "dob": datetime.datetime(1990, 1, 1)},
284
+ {"id": "1-2-3-5", "name": "Jane", "dob": datetime.datetime(2020, 1, 1)},
285
+ {"id": "1-2-3-6", "name": "Greg", "dob": datetime.datetime(1980, 1, 1)},
286
+ ]
287
+ },
288
+ ]
289
+ }
290
+ )
291
+ wsgi()
292
+ ```
293
+
294
+ Which gives:
295
+
296
+ ```bash
297
+ $ curl 'http://localhost:8080/jane' | jq
298
+ {
299
+ "status": "success",
300
+ "error": "",
301
+ "data": [
302
+ {
303
+ "id": "1-2-3-5",
304
+ "name": "Jane",
305
+ "age": 5,
306
+ "is_special": true
307
+ }
308
+ {
309
+ "id": "1-2-3-4",
310
+ "name": "Bob",
311
+ "age": 35,
312
+ "is_special": false
313
+ },
314
+ {
315
+ "id": "1-2-3-6",
316
+ "name": "Greg",
317
+ "age": 45,
318
+ "is_special": false
319
+ },
320
+ ],
321
+ "pagination": {
322
+ "number_results": 3,
323
+ "limit": 50,
324
+ "next_page": {}
325
+ },
326
+ "input_errors": {}
327
+ }
328
+
329
+ ```
330
+
331
+ """
332
+ output_map = clearskies.configs.Callable(default=None)
333
+
334
+ """
335
+ A schema that describes the expected output to the client.
336
+
337
+ This is used to build the auto-documentation. See the documentation for clearskies.endpoint.output_map for examples.
338
+ Note that this is typically not required - when returning models and relying on clearskies to auto-convert to JSON,
339
+ it will also automatically generate your documentation.
340
+ """
341
+ output_schema = clearskies.configs.Schema(default=None)
342
+
343
+ """
344
+ The model class used by this endpoint.
345
+
346
+ The endpoint will use this to fetch/save/validate incoming data as needed.
347
+ """
348
+ model_class = clearskies.configs.ModelClass(default=None)
349
+
350
+ """
351
+ Columns from the model class that should be returned to the client.
352
+
353
+ Most endpoints use a model to build the return response to the user. In this case, `readable_column_names`
354
+ instructs the model what columns should be sent back to the user. This information is similarly used when generating
355
+ the documentation for the endpoint.
356
+
357
+ ```python
358
+ import clearskies
359
+
360
+ class User(clearskies.Model):
361
+ id_column_name = "id"
362
+ backend = clearskies.backends.MemoryBackend()
363
+ id = clearskies.columns.Uuid()
364
+ name = clearskies.columns.String()
365
+ secret = clearskies.columns.String()
366
+
367
+ list_users = clearskies.endpoints.List(
368
+ model_class=User,
369
+ readable_column_names=["id", "name"],
370
+ sortable_column_names=["id", "name"],
371
+ default_sort_column_name="name",
372
+ )
373
+
374
+ wsgi = clearskies.contexts.WsgiRef(
375
+ list_users,
376
+ classes=[User],
377
+ bindings={
378
+ "memory_backend_default_data": [
379
+ {
380
+ "model_class": User,
381
+ "records": [
382
+ {"id": "1-2-3-4", "name": "Bob", "secret": "Awesome dude"},
383
+ {"id": "1-2-3-5", "name": "Jane", "secret": "Gets things done"},
384
+ {"id": "1-2-3-6", "name": "Greg", "secret": "Loves chocolate"},
385
+ ]
386
+ },
387
+ ]
388
+ }
389
+ )
390
+ wsgi()
391
+ ```
392
+
393
+ And then:
394
+
395
+ ```bash
396
+ $ curl 'http://localhost:8080'
397
+ {
398
+ "status": "success",
399
+ "error": "",
400
+ "data": [
401
+ {
402
+ "id": "1-2-3-4",
403
+ "name": "Bob"
404
+ },
405
+ {
406
+ "id": "1-2-3-6",
407
+ "name": "Greg"
408
+ },
409
+ {
410
+ "id": "1-2-3-5",
411
+ "name": "Jane"
412
+ }
413
+ ],
414
+ "pagination": {
415
+ "number_results": 3,
416
+ "limit": 50,
417
+ "next_page": {}
418
+ },
419
+ "input_errors": {}
420
+ }
421
+
422
+ ```
423
+ """
424
+ readable_column_names = clearskies.configs.ReadableModelColumns("model_class", default=[])
425
+
426
+ """
427
+ Specifies which columns from a model class can be set by the client.
428
+
429
+ Many endpoints allow or require input from the client. The most common way to provide input validation
430
+ is by setting the model class and using `writeable_column_names` to specify which columns the end client can
431
+ set. Clearskies will then use the model schema to validate the input and also auto-generate documentation
432
+ for the endpoint.
433
+
434
+ ```python
435
+ import clearskies
436
+
437
+ class User(clearskies.Model):
438
+ id_column_name = "id"
439
+ backend = clearskies.backends.MemoryBackend()
440
+ id = clearskies.columns.Uuid()
441
+ name = clearskies.columns.String(validators=[clearskies.validators.Required()])
442
+ date_of_birth = clearskies.columns.Date()
443
+
444
+ send_user = clearskies.endpoints.Callable(
445
+ lambda request_data: request_data,
446
+ request_methods=["GET","POST"],
447
+ writeable_column_names=["name", "date_of_birth"],
448
+ model_class=User,
449
+ )
450
+
451
+ wsgi = clearskies.contexts.WsgiRef(send_user)
452
+ wsgi()
453
+ ```
454
+
455
+ If we send a valid payload:
456
+
457
+ ```bash
458
+ $ curl 'http://localhost:8080' -d '{"name":"Jane","date_of_birth":"01/01/1990"}' | jq
459
+ {
460
+ "status": "success",
461
+ "error": "",
462
+ "data": {
463
+ "name": "Jane",
464
+ "date_of_birth": "01/01/1990"
465
+ },
466
+ "pagination": {},
467
+ "input_errors": {}
468
+ }
469
+ ```
470
+
471
+ And we can see the automatic input validation by sending some incorrect data:
472
+
473
+ ```bash
474
+ $ curl 'http://localhost:8080' -d '{"name":"","date_of_birth":"this is not a date","id":"hey"}' | jq
475
+ {
476
+ "status": "input_errors",
477
+ "error": "",
478
+ "data": [],
479
+ "pagination": {},
480
+ "input_errors": {
481
+ "name": "'name' is required.",
482
+ "date_of_birth": "given value did not appear to be a valid date",
483
+ "other_column": "Input column other_column is not an allowed input column."
484
+ }
485
+ }
486
+ ```
487
+
488
+ """
489
+ writeable_column_names = clearskies.configs.WriteableModelColumns("model_class", default=[])
490
+
491
+ """
492
+ Columns from the model class that can be searched by the client.
493
+
494
+ Sets which columns the client is allowed to search (for endpoints that support searching).
495
+ """
496
+ searchable_column_names = clearskies.configs.SearchableModelColumns("model_class", default=[])
497
+
498
+ """
499
+ A function to call to add custom input validation logic.
500
+
501
+ Typically, input validation happens by choosing the appropriate column in your schema and adding validators where necessary. You
502
+ can also create custom columns with their own input validation logic. However, if desired, endpoints that accept user input also
503
+ allow you to add callables for custom validation logic. These functions should return a dictionary where the key name
504
+ represents the name of the column that has invalid input, and the value is a human-readable error message. If no input errors are
505
+ found, then the callable should return an empty dictionary. As usual, the callable can request any standard dependencies configured
506
+ in the dependency injection container or proivded by input_output.get_context_for_callables.
507
+
508
+ Note that most endpoints (such as Create and Update) explicitly require input. As a result, if a request comes in without input
509
+ from the end user, it will be rejected before calling your input validator. In these cases you can depend on request_data always
510
+ being a dictionary. The Callable endpoint, however, only requires input if `writeable_column_names` is set. If it's not set,
511
+ and the end-user doesn't provide a request body, then request_data will be None.
512
+
513
+ ```python
514
+ import clearskies
515
+
516
+ def check_input(request_data):
517
+ if not request_data:
518
+ return {}
519
+ if request_data.get("name"):
520
+ return {"name":"This is a privacy-preserving system, so please don't tell us your name"}
521
+ return {}
522
+
523
+ send_user = clearskies.endpoints.Callable(
524
+ lambda request_data: request_data,
525
+ request_methods=["GET", "POST"],
526
+ input_validation_callable=check_input,
527
+ )
528
+
529
+ wsgi = clearskies.contexts.WsgiRef(send_user)
530
+ wsgi()
531
+ ```
532
+
533
+ And when invoked:
534
+
535
+ ```bash
536
+ $ curl http://localhost:8080 -d '{"name":"sup"}' | jq
537
+ {
538
+ "status": "input_errors",
539
+ "error": "",
540
+ "data": [],
541
+ "pagination": {},
542
+ "input_errors": {
543
+ "name": "This is a privacy-preserving system, so please don't tell us your name"
544
+ }
545
+ }
546
+
547
+ $ curl http://localhost:8080 -d '{"hello":"world"}' | jq
548
+ {
549
+ "status": "success",
550
+ "error": "",
551
+ "data": {
552
+ "hello": "world"
553
+ },
554
+ "pagination": {},
555
+ "input_errors": {}
556
+ }
557
+ ```
558
+
559
+ """
560
+ input_validation_callable = clearskies.configs.Callable(default=None)
561
+
562
+ """
563
+ A dictionary with columns that should override columns in the model.
564
+
565
+ This is typically used to change column definitions on specific endpoints to adjust behavior: for intstance a model might use a `created_by_*`
566
+ column to auto-populate some data, but an admin endpoint may need to override that behavior so the user can set it directly.
567
+
568
+ This should be a dictionary with the column name as a key and the column itself as the value. Note that you cannot use this to remove
569
+ columns from the model. In general, if you want a column not to be exposed through an endpoint, then all you have to do is remove
570
+ that column from the list of writeable columns.
571
+
572
+ ```python
573
+ import clearskies
574
+
575
+ endpoint = clearskies.Endpoint(
576
+ column_overrides = {
577
+ "name": clearskies.columns.String(validators=clearskies.validators.Required()),
578
+ }
579
+ )
580
+ ```
581
+ """
582
+ column_overrides = clearskies.configs.Columns(default={})
583
+
584
+ """
585
+ Used in conjunction with external_casing to change the casing of the key names in the outputted JSON of the endpoint.
586
+
587
+ To use these, set internal_casing to the casing scheme used in your model, and then set external_casing to the casing
588
+ scheme you want for your API endpoints. clearskies will then automatically convert all output key names accordingly.
589
+ Note that for callables, this only works when you return a model and set `readable_columns`. If you set `writeable_columns`,
590
+ it will also map the incoming data.
591
+
592
+ The allowed casing schemas are:
593
+
594
+ 1. `snake_case`
595
+ 2. `camelCase`
596
+ 3. `TitleCase`
597
+
598
+ By default internal_casing and external_casing are both set to 'snake_case', which means that no conversion happens.
599
+
600
+ ```python
601
+ import clearskies
602
+ import datetime
603
+
604
+ class User(clearskies.Model):
605
+ id_column_name = "id"
606
+ backend = clearskies.backends.MemoryBackend()
607
+ id = clearskies.columns.Uuid()
608
+ name = clearskies.columns.String()
609
+ date_of_birth = clearskies.columns.Date()
610
+
611
+ send_user = clearskies.endpoints.Callable(
612
+ lambda users: users.create({"name":"Example","date_of_birth": datetime.datetime(2050, 1, 15)}),
613
+ readable_column_names=["name", "date_of_birth"],
614
+ internal_casing="snake_case",
615
+ external_casing="TitleCase",
616
+ model_class=User,
617
+ )
618
+
619
+ # because we're using name-based injection in our lambda callable (instead of type hinting) we have to explicitly
620
+ # add the user model to the dependency injection container
621
+ wsgi = clearskies.contexts.WsgiRef(send_user, classes=[User])
622
+ wsgi()
623
+ ```
624
+
625
+ And then when called:
626
+
627
+ ```bash
628
+ $ curl http://localhost:8080 | jq
629
+ {
630
+ "Status": "Success",
631
+ "Error": "",
632
+ "Data": {
633
+ "Name": "Example",
634
+ "DateOfBirth": "2050-01-15"
635
+ },
636
+ "Pagination": {},
637
+ "InputErrors": {}
638
+ }
639
+ ```
640
+ """
641
+ internal_casing = clearskies.configs.Select(["snake_case", "camelCase", "TitleCase"], default="snake_case")
642
+
643
+ """
644
+ Used in conjunction with internal_casing to change the casing of the key names in the outputted JSON of the endpoint.
645
+
646
+ See the docs for `internal_casing` for more details and usage examples.
647
+ """
648
+ external_casing = clearskies.configs.Select(["snake_case", "camelCase", "TitleCase"], default="snake_case")
649
+
650
+ """
651
+ Configure standard security headers to be sent along in the response from this endpoint.
652
+
653
+ Note that, with CORS, you generally only have to specify the origin. The routing system will automatically add
654
+ in the appropriate HTTP verbs, and the authorization classes will add in the appropriate headers.
655
+
656
+ ```python
657
+ import clearskies
658
+
659
+ hello_world = clearskies.endpoints.Callable(
660
+ lambda: {"hello": "world"},
661
+ request_methods=["PATCH", "POST"],
662
+ authentication=clearskies.authentication.SecretBearer(environment_key="MY_SECRET"),
663
+ security_headers=[
664
+ clearskies.security_headers.Hsts(),
665
+ clearskies.security_headers.Cors(origin="https://example.com"),
666
+ ],
667
+ )
668
+
669
+ wsgi = clearskies.contexts.WsgiRef(hello_world)
670
+ wsgi()
671
+ ```
672
+
673
+ And then execute the options endpoint to see all the security headers:
674
+
675
+ ```bash
676
+ $ curl -v http://localhost:8080 -X OPTIONS
677
+ * Host localhost:8080 was resolved.
678
+ < HTTP/1.0 200 Ok
679
+ < Server: WSGIServer/0.2 CPython/3.11.6
680
+ < ACCESS-CONTROL-ALLOW-METHODS: PATCH, POST
681
+ < ACCESS-CONTROL-ALLOW-HEADERS: Authorization
682
+ < ACCESS-CONTROL-MAX-AGE: 5
683
+ < ACCESS-CONTROL-ALLOW-ORIGIN: https://example.com
684
+ < STRICT-TRANSPORT-SECURITY: max-age=31536000 ;
685
+ < CONTENT-TYPE: application/json; charset=UTF-8
686
+ < Content-Length: 0
687
+ <
688
+ * Closing connection
689
+ ```
690
+
691
+ """
692
+ security_headers = clearskies.configs.SecurityHeaders(default=[])
693
+
694
+ """
695
+ A description for this endpoint. This is added to any auto-documentation
696
+ """
697
+ description = clearskies.configs.String(default="")
698
+
699
+ """
700
+ Whether or not the routing data should also be persisted to the model. Defaults to False.
701
+
702
+ Note: this is only relevant for handlers that accept request data
703
+ """
704
+ include_routing_data_in_request_data = clearskies.configs.Boolean(default=False)
705
+
706
+ """
707
+ Additional conditions to always add to the results.
708
+
709
+ where should be a single item or a list of items containing one of three things:
710
+
711
+ 1. Conditions expressed as a string (e.g. `"name=example"`, `"age>5"`)
712
+ 2. Queries built with a column (e.g. `SomeModel.name.equals("example")`, `SomeModel.age.greater_than(5)`)
713
+ 3. A callable which accepts and returns the mode (e.g. `lambda model: model.where("name=example")`)
714
+
715
+ Here's an example:
716
+
717
+ ```python
718
+ import clearskies
719
+
720
+ class Student(clearskies.Model):
721
+ backend = clearskies.backends.MemoryBackend()
722
+ id_column_name = "id"
723
+
724
+ id = clearskies.columns.Uuid()
725
+ name = clearskies.columns.String()
726
+ grade = clearskies.columns.Integer()
727
+ will_graduate = clearskies.columns.Boolean()
728
+
729
+ wsgi = clearskies.contexts.WsgiRef(
730
+ clearskies.endpoints.List(
731
+ Student,
732
+ readable_column_names=["id", "name", "grade"],
733
+ sortable_column_names=["name", "grade"],
734
+ default_sort_column_name="name",
735
+ where=["grade<10", Student.will_graduate.equals(True)],
736
+ ),
737
+ bindings={
738
+ "memory_backend_default_data": [
739
+ {
740
+ "model_class": Student,
741
+ "records": [
742
+ {"id": "1-2-3-4", "name": "Bob", "grade": 5, "will_graduate": True},
743
+ {"id": "1-2-3-5", "name": "Jane", "grade": 3, "will_graduate": True},
744
+ {"id": "1-2-3-6", "name": "Greg", "grade": 3, "will_graduate": False},
745
+ {"id": "1-2-3-7", "name": "Bob", "grade": 2, "will_graduate": True},
746
+ {"id": "1-2-3-8", "name": "Ann", "grade": 12, "will_graduate": True},
747
+ ],
748
+ },
749
+ ],
750
+ },
751
+ )
752
+ wsgi()
753
+ ```
754
+
755
+ Which you can invoke:
756
+
757
+ ```bash
758
+ $ curl 'http://localhost:8080/' | jq
759
+ {
760
+ "status": "success",
761
+ "error": "",
762
+ "data": [
763
+ {
764
+ "id": "1-2-3-4",
765
+ "name": "Bob",
766
+ "grade": 5
767
+ },
768
+ {
769
+ "id": "1-2-3-7",
770
+ "name": "Bob",
771
+ "grade": 2
772
+ },
773
+ {
774
+ "id": "1-2-3-5",
775
+ "name": "Jane",
776
+ "grade": 3
777
+ }
778
+ ],
779
+ "pagination": {},
780
+ "input_errors": {}
781
+ }
782
+ ```
783
+ and note that neither Greg nor Ann are returned. Ann because she doesn't make the grade criteria, and Greg because
784
+ he won't graduate.
785
+ """
786
+ where = clearskies.configs.Conditions(default=[])
787
+
788
+ """
789
+ Additional joins to always add to the query.
790
+
791
+ ```python
792
+ import clearskies
793
+
794
+ class Student(clearskies.Model):
795
+ backend = clearskies.backends.MemoryBackend()
796
+ id_column_name = "id"
797
+
798
+ id = clearskies.columns.Uuid()
799
+ name = clearskies.columns.String()
800
+ grade = clearskies.columns.Integer()
801
+ will_graduate = clearskies.columns.Boolean()
802
+
803
+ class PastRecord(clearskies.Model):
804
+ backend = clearskies.backends.MemoryBackend()
805
+ id_column_name = "id"
806
+
807
+ id = clearskies.columns.Uuid()
808
+ student_id = clearskies.columns.BelongsToId(Student)
809
+ school_name = clearskies.columns.String()
810
+
811
+ wsgi = clearskies.contexts.WsgiRef(
812
+ clearskies.endpoints.List(
813
+ Student,
814
+ readable_column_names=["id", "name", "grade"],
815
+ sortable_column_names=["name", "grade"],
816
+ default_sort_column_name="name",
817
+ joins=["INNER JOIN past_records ON past_records.student_id=students.id"],
818
+ ),
819
+ bindings={
820
+ "memory_backend_default_data": [
821
+ {
822
+ "model_class": Student,
823
+ "records": [
824
+ {"id": "1-2-3-4", "name": "Bob", "grade": 5, "will_graduate": True},
825
+ {"id": "1-2-3-5", "name": "Jane", "grade": 3, "will_graduate": True},
826
+ {"id": "1-2-3-6", "name": "Greg", "grade": 3, "will_graduate": False},
827
+ {"id": "1-2-3-7", "name": "Bob", "grade": 2, "will_graduate": True},
828
+ {"id": "1-2-3-8", "name": "Ann", "grade": 12, "will_graduate": True},
829
+ ],
830
+ },
831
+ {
832
+ "model_class": PastRecord,
833
+ "records": [
834
+ {"id": "5-2-3-4", "student_id": "1-2-3-4", "school_name": "Best Academy"},
835
+ {"id": "5-2-3-5", "student_id": "1-2-3-5", "school_name": "Awesome School"},
836
+ ],
837
+ },
838
+ ],
839
+ },
840
+ )
841
+ wsgi()
842
+ ```
843
+
844
+ Which when invoked:
845
+
846
+ ```bash
847
+ $ curl 'http://localhost:8080/' | jq
848
+ {
849
+ "status": "success",
850
+ "error": "",
851
+ "data": [
852
+ {
853
+ "id": "1-2-3-4",
854
+ "name": "Bob",
855
+ "grade": 5
856
+ },
857
+ {
858
+ "id": "1-2-3-5",
859
+ "name": "Jane",
860
+ "grade": 3
861
+ }
862
+ ],
863
+ "pagination": {},
864
+ "input_errors": {}
865
+ }
866
+ ```
867
+
868
+ e.g., the inner join reomves all the students that don't have an entry in the PastRecord model.
869
+
870
+ """
871
+ joins = clearskies.configs.Joins(default=[])
872
+
873
+ cors_header: Cors = None # type: ignore
874
+ _model: clearskies.model.Model = None # type: ignore
875
+ _columns: dict[str, clearskies.column.Column] = None # type: ignore
876
+ _readable_columns: dict[str, clearskies.column.Column] = None # type: ignore
877
+ _writeable_columns: dict[str, clearskies.column.Column] = None # type: ignore
878
+ _searchable_columns: dict[str, clearskies.column.Column] = None # type: ignore
879
+ _sortable_columns: dict[str, clearskies.column.Column] = None # type: ignore
880
+ _as_json_map: dict[str, clearskies.column.Column] = None # type: ignore
881
+
882
+ @clearskies.decorators.parameters_to_properties
883
+ def __init__(
884
+ self,
885
+ url: str = "",
886
+ request_methods: list[str] = ["GET"],
887
+ response_headers: list[str | Callable[..., list[str]]] = [],
888
+ output_map: Callable[..., dict[str, Any]] | None = None,
889
+ column_overrides: dict[str, Column] = {},
890
+ internal_casing: str = "snake_case",
891
+ external_casing: str = "snake_case",
892
+ security_headers: list[SecurityHeader] = [],
893
+ description: str = "",
894
+ authentication: Authentication = Public(),
895
+ authorization: Authorization = Authorization(),
896
+ ):
897
+ self.finalize_and_validate_configuration()
898
+ for security_header in self.security_headers:
899
+ if not security_header.is_cors:
900
+ continue
901
+ self.cors_header = security_header # type: ignore
902
+ self.has_cors = True
903
+ break
904
+
905
+ @property
906
+ def model(self) -> Model:
907
+ if self._model is None:
908
+ self._model = self.di.build(self.model_class)
909
+ return self._model
910
+
911
+ @property
912
+ def columns(self) -> dict[str, Column]:
913
+ if self._columns is None:
914
+ self._columns = self.model.get_columns()
915
+ return self._columns
916
+
917
+ @property
918
+ def readable_columns(self) -> dict[str, Column]:
919
+ if self._readable_columns is None:
920
+ self._readable_columns = {name: self.columns[name] for name in self.readable_column_names}
921
+ return self._readable_columns
922
+
923
+ @property
924
+ def writeable_columns(self) -> dict[str, Column]:
925
+ if self._writeable_columns is None:
926
+ self._writeable_columns = {name: self.columns[name] for name in self.writeable_column_names}
927
+ return self._writeable_columns
928
+
929
+ @property
930
+ def searchable_columns(self) -> dict[str, Column]:
931
+ if self._searchable_columns is None:
932
+ self._searchable_columns = {name: self._columns[name] for name in self.sortable_column_names}
933
+ return self._searchable_columns
934
+
935
+ @property
936
+ def sortable_columns(self) -> dict[str, Column]:
937
+ if self._sortable_columns is None:
938
+ self._sortable_columns = {name: self._columns[name] for name in self.sortable_column_names}
939
+ return self._sortable_columns
940
+
941
+ def get_request_data(self, input_output: InputOutput, required=True) -> dict[str, Any]:
942
+ if not input_output.request_data:
943
+ if input_output.has_body():
944
+ raise exceptions.ClientError("Request body was not valid JSON")
945
+ raise exceptions.ClientError("Missing required JSON body")
946
+ if not isinstance(input_output.request_data, dict):
947
+ raise exceptions.ClientError("Request body was not a JSON dictionary.")
948
+
949
+ return {
950
+ **input_output.request_data, # type: ignore
951
+ **(input_output.routing_data if self.include_routing_data_in_request_data else {}),
952
+ }
953
+
954
+ def fetch_model_with_base_query(self, input_output: InputOutput) -> Model:
955
+ model = self.model
956
+ for join in self.joins:
957
+ if callable(join):
958
+ model = self.di.call_function(join, model=model, **input_output.get_context_for_callables())
959
+ else:
960
+ model = model.join(join)
961
+ for where in self.where:
962
+ if callable(where):
963
+ model = self.di.call_function(where, model=model, **input_output.get_context_for_callables())
964
+ else:
965
+ model = model.where(where)
966
+ model = model.where_for_request_all(
967
+ model,
968
+ input_output,
969
+ input_output.routing_data,
970
+ input_output.authorization_data,
971
+ overrides=self.column_overrides,
972
+ )
973
+ return self.authorization.filter_model(model, input_output.authorization_data, input_output)
974
+
975
+ def handle(self, input_output: InputOutput) -> Any:
976
+ raise NotImplementedError()
977
+
978
+ def matches_request(self, input_output: InputOutput, allow_partial=False) -> bool:
979
+ """Whether or not we can handle an incoming request based on URL and request method."""
980
+ # soo..... this excessively duplicates the logic in __call__, but I'm being lazy right now
981
+ # and not fixing it.
982
+ request_method = input_output.get_request_method().upper()
983
+ if request_method == "OPTIONS":
984
+ return True
985
+ if request_method not in self.request_methods:
986
+ return False
987
+ expected_url = self.url.strip("/")
988
+ incoming_url = input_output.get_full_path().strip("/")
989
+ if not expected_url and not incoming_url:
990
+ return True
991
+
992
+ matches, routing_data = routing.match_route(expected_url, incoming_url, allow_partial=allow_partial)
993
+ return matches
994
+
995
+ def populate_routing_data(self, input_output: InputOutput) -> Any:
996
+ # matches_request is only checked by the endpoint group, not by the context. As a result, we need to check our
997
+ # route. However we always have to check our route anyway because the full routing data can only be figured
998
+ # out at the endpoint level, so calling out to routing.mattch_route is unavoidable.
999
+ request_method = input_output.get_request_method().upper()
1000
+ if request_method == "OPTIONS":
1001
+ return self.cors(input_output)
1002
+ if request_method not in self.request_methods:
1003
+ return self.error(input_output, "Not Found", 404)
1004
+ expected_url = self.url.strip("/")
1005
+ incoming_url = input_output.get_full_path().strip("/")
1006
+ if expected_url or incoming_url:
1007
+ matches, routing_data = routing.match_route(expected_url, incoming_url, allow_partial=False)
1008
+ if not matches:
1009
+ return self.error(input_output, "Not Found", 404)
1010
+ input_output.routing_data = routing_data
1011
+
1012
+ def failure(self, input_output: InputOutput) -> Any:
1013
+ return self.respond_json(input_output, {"status": "failure"}, 500)
1014
+
1015
+ def input_errors(self, input_output: InputOutput, errors: dict[str, str], status_code: int = 200) -> Any:
1016
+ """Return input errors to the client."""
1017
+ return self.respond_json(input_output, {"status": "input_errors", "input_errors": errors}, status_code)
1018
+
1019
+ def error(self, input_output: InputOutput, message: str, status_code: int) -> Any:
1020
+ """Return a client-side error (e.g. 400)."""
1021
+ return self.respond_json(input_output, {"status": "client_error", "error": message}, status_code)
1022
+
1023
+ def redirect(self, input_output: InputOutput, location: str, status_code: int) -> Any:
1024
+ """Return a redirect."""
1025
+ input_output.response_headers.add("content-type", "text/html")
1026
+ input_output.response_headers.add("location", location)
1027
+ return self.respond(
1028
+ '<meta http-equiv="refresh" content="0; url=' + urllib.parse.quote(location) + '">Redirecting', status_code
1029
+ )
1030
+
1031
+ def success(
1032
+ self,
1033
+ input_output: InputOutput,
1034
+ data: dict[str, Any] | list[Any],
1035
+ number_results: int | None = None,
1036
+ limit: int | None = None,
1037
+ next_page: Any = None,
1038
+ ) -> Any:
1039
+ """Return a successful response."""
1040
+ response_data = {"status": "success", "data": data, "pagination": {}}
1041
+
1042
+ if next_page or number_results:
1043
+ if number_results is not None:
1044
+ for value in [number_results, limit]:
1045
+ if value is not None and type(value) != int:
1046
+ raise ValueError("number_results and limit must all be integers")
1047
+
1048
+ response_data["pagination"] = {
1049
+ "number_results": number_results,
1050
+ "limit": limit,
1051
+ "next_page": next_page,
1052
+ }
1053
+
1054
+ return self.respond_json(input_output, response_data, 200)
1055
+
1056
+ def model_as_json(self, model: clearskies.model.Model, input_output: InputOutput) -> dict[str, Any]:
1057
+ if self.output_map:
1058
+ return self.di.call_function(self.output_map, model=model, **input_output.get_context_for_callables())
1059
+
1060
+ if self._as_json_map is None:
1061
+ self._as_json_map = self._build_as_json_map(model)
1062
+
1063
+ json = OrderedDict()
1064
+ for output_name, column in self._as_json_map.items():
1065
+ column_data = column.to_json(model)
1066
+ if len(column_data) == 1:
1067
+ json[output_name] = list(column_data.values())[0]
1068
+ else:
1069
+ for key, value in column_data.items():
1070
+ json[self.auto_case_column_name(key, True)] = value
1071
+ return json
1072
+
1073
+ def _build_as_json_map(self, model: clearskies.model.Model) -> dict[str, clearskies.column.Column]:
1074
+ conversion_map = {}
1075
+ if not self.readable_column_names:
1076
+ raise ValueError(
1077
+ "I was asked to convert a model to JSON but I wasn't provided with `readable_column_names'"
1078
+ )
1079
+ for column in self.readable_columns.values():
1080
+ conversion_map[self.auto_case_column_name(column.name, True)] = column
1081
+ return conversion_map
1082
+
1083
+ def validate_input_against_schema(
1084
+ self, request_data: dict[str, Any], input_output: InputOutput, schema: Schema | type[Schema]
1085
+ ) -> None:
1086
+ if not self.writeable_column_names:
1087
+ raise ValueError(
1088
+ f"I was asked to validate input against a schema, but no writeable columns are defined, so I can't :( This is probably a bug in the endpoint class - {self.__class__.__name__}."
1089
+ )
1090
+ request_data = self.map_request_data_external_to_internal(request_data)
1091
+ self.find_input_errors(request_data, input_output, schema)
1092
+
1093
+ def map_request_data_external_to_internal(self, request_data, required=True):
1094
+ # we have to map from internal names to external names, because case mapping
1095
+ # isn't always one-to-one, so we want to do it exactly the same way that the documentation
1096
+ # is built.
1097
+ key_map = {self.auto_case_column_name(key, True): key for key in self.writeable_column_names}
1098
+
1099
+ # and make sure we don't drop any data along the way, because the input validation
1100
+ # needs to return an error for unexpected data.
1101
+ return {key_map.get(key, key): value for (key, value) in request_data.items()}
1102
+
1103
+ def find_input_errors(
1104
+ self, request_data: dict[str, Any], input_output: InputOutput, schema: Schema | type[Schema]
1105
+ ) -> None:
1106
+ input_errors: dict[str, str] = {}
1107
+ columns = schema.get_columns()
1108
+ model = self.di.build(schema) if inspect.isclass(schema) else schema
1109
+ for column_name in self.writeable_column_names:
1110
+ column = columns[column_name]
1111
+ input_errors = {
1112
+ **input_errors,
1113
+ **column.input_errors(model, request_data), # type: ignore
1114
+ }
1115
+ input_errors = {
1116
+ **input_errors,
1117
+ **self.find_input_errors_from_callable(request_data, input_output),
1118
+ }
1119
+ for extra_column_name in set(request_data.keys()) - set(self.writeable_column_names):
1120
+ external_column_name = self.auto_case_column_name(extra_column_name, False)
1121
+ input_errors[external_column_name] = f"Input column {external_column_name} is not an allowed input column."
1122
+ if input_errors:
1123
+ raise exceptions.InputErrors(input_errors)
1124
+
1125
+ def find_input_errors_from_callable(
1126
+ self, request_data: dict[str, Any] | list[Any] | None, input_output: InputOutput
1127
+ ) -> dict[str, str]:
1128
+ if not self.input_validation_callable:
1129
+ return {}
1130
+
1131
+ more_input_errors = self.di.call_function(
1132
+ self.input_validation_callable, **input_output.get_context_for_callables()
1133
+ )
1134
+ if not isinstance(more_input_errors, dict):
1135
+ raise ValueError("The input error callable did not return a dictionary as required")
1136
+ return more_input_errors
1137
+
1138
+ def cors(self, input_output: InputOutput):
1139
+ cors_header = self.cors_header if self.cors_header else Cors()
1140
+ for method in self.request_methods:
1141
+ cors_header.add_method(method)
1142
+ if self.authentication:
1143
+ self.authentication.set_headers_for_cors(cors_header)
1144
+ cors_header.set_headers_for_input_output(input_output)
1145
+ for security_header in self.security_headers:
1146
+ if security_header.is_cors:
1147
+ continue
1148
+ security_header.set_headers_for_input_output(input_output)
1149
+ return input_output.respond("", 200)
1150
+
1151
+ def documentation(self) -> list[Request]:
1152
+ return []
1153
+
1154
+ def documentation_components(self) -> dict[str, Any]:
1155
+ return {
1156
+ "models": self.documentation_models(),
1157
+ "securitySchemes": self.documentation_security_schemes(),
1158
+ }
1159
+
1160
+ def documentation_security_schemes(self) -> dict[str, Any]:
1161
+ if not self.authentication or not self.authentication.documentation_security_scheme_name():
1162
+ return {}
1163
+
1164
+ return {
1165
+ self.authentication.documentation_security_scheme_name(): (
1166
+ self.authentication.documentation_security_scheme()
1167
+ ),
1168
+ }
1169
+
1170
+ def documentation_models(self) -> dict[str, schema.Schema]:
1171
+ return {}
1172
+
1173
+ def documentation_pagination_response(self, include_pagination=True) -> schema.Schema:
1174
+ if not include_pagination:
1175
+ return schema.Object(self.auto_case_internal_column_name("pagination"), [], value={})
1176
+ model = self.di.build(self.model_class)
1177
+ return schema.Object(
1178
+ self.auto_case_internal_column_name("pagination"),
1179
+ [
1180
+ schema.Integer(self.auto_case_internal_column_name("number_results"), example=10),
1181
+ schema.Integer(self.auto_case_internal_column_name("limit"), example=100),
1182
+ schema.Object(
1183
+ self.auto_case_internal_column_name("next_page"),
1184
+ model.documentation_pagination_next_page_response(self.auto_case_internal_column_name),
1185
+ model.documentation_pagination_next_page_example(self.auto_case_internal_column_name),
1186
+ ),
1187
+ ],
1188
+ )
1189
+
1190
+ def documentation_success_response(
1191
+ self, data_schema: schema.Object | schema.Array, description: str = "", include_pagination: bool = False
1192
+ ) -> Response:
1193
+ return Response(
1194
+ 200,
1195
+ schema.Object(
1196
+ "body",
1197
+ [
1198
+ schema.String(self.auto_case_internal_column_name("status"), value="success"),
1199
+ data_schema,
1200
+ self.documentation_pagination_response(include_pagination=include_pagination),
1201
+ schema.String(self.auto_case_internal_column_name("error"), value=""),
1202
+ schema.Object(self.auto_case_internal_column_name("input_errors"), [], value={}),
1203
+ ],
1204
+ ),
1205
+ description=description,
1206
+ )
1207
+
1208
+ def documentation_generic_error_response(self, description="Invalid Call", status=400) -> Response:
1209
+ return Response(
1210
+ status,
1211
+ schema.Object(
1212
+ "body",
1213
+ [
1214
+ schema.String(self.auto_case_internal_column_name("status"), value="error"),
1215
+ schema.Object(self.auto_case_internal_column_name("data"), [], value={}),
1216
+ self.documentation_pagination_response(include_pagination=False),
1217
+ schema.String(self.auto_case_internal_column_name("error"), example="User readable error message"),
1218
+ schema.Object(self.auto_case_internal_column_name("input_errors"), [], value={}),
1219
+ ],
1220
+ ),
1221
+ description=description,
1222
+ )
1223
+
1224
+ def documentation_input_error_response(self, description="Invalid client-side input") -> Response:
1225
+ email_example = self.auto_case_internal_column_name("email")
1226
+ return Response(
1227
+ 200,
1228
+ schema.Object(
1229
+ "body",
1230
+ [
1231
+ schema.String(self.auto_case_internal_column_name("status"), value="input_errors"),
1232
+ schema.Object(self.auto_case_internal_column_name("data"), [], value={}),
1233
+ self.documentation_pagination_response(include_pagination=False),
1234
+ schema.String(self.auto_case_internal_column_name("error"), value=""),
1235
+ schema.Object(
1236
+ self.auto_case_internal_column_name("input_errors"),
1237
+ [schema.String("[COLUMN_NAME]", example="User friendly error message")],
1238
+ example={email_example: f"{email_example} was not a valid email address"},
1239
+ ),
1240
+ ],
1241
+ ),
1242
+ description=description,
1243
+ )
1244
+
1245
+ def documentation_access_denied_response(self) -> Response:
1246
+ return self.documentation_generic_error_response(description="Access Denied", status=401)
1247
+
1248
+ def documentation_unauthorized_response(self) -> Response:
1249
+ return self.documentation_generic_error_response(description="Unauthorized", status=403)
1250
+
1251
+ def documentation_not_found(self) -> Response:
1252
+ return self.documentation_generic_error_response(description="Not Found", status=404)
1253
+
1254
+ def documentation_request_security(self):
1255
+ authentication = self.authentication
1256
+ name = authentication.documentation_security_scheme_name()
1257
+ return [{name: []}] if name else []
1258
+
1259
+ def documentation_data_schema(
1260
+ self, schema: type[Schema] | None = None, column_names: list[str] = []
1261
+ ) -> list[schema.Schema]:
1262
+ if schema is None:
1263
+ schema = self.model_class
1264
+ readable_column_names = [*column_names]
1265
+ if not readable_column_names and self.readable_column_names:
1266
+ readable_column_names: list[str] = self.readable_column_names # type: ignore
1267
+ properties = []
1268
+
1269
+ columns = schema.get_columns()
1270
+ for column_name in readable_column_names:
1271
+ column = columns[column_name]
1272
+ for doc in column.documentation():
1273
+ doc.name = self.auto_case_internal_column_name(doc.name)
1274
+ properties.append(doc)
1275
+
1276
+ return properties
1277
+
1278
+ def standard_json_request_parameters(
1279
+ self, schema: type[Schema] | None = None, column_names: list[str] = []
1280
+ ) -> list[Parameter]:
1281
+ if not column_names:
1282
+ if not self.writeable_column_names:
1283
+ return []
1284
+ column_names = self.writeable_column_names
1285
+
1286
+ if not schema:
1287
+ if not self.model_class:
1288
+ return []
1289
+ schema = self.model_class
1290
+
1291
+ model_name = string.camel_case_to_snake_case(schema.__name__)
1292
+ columns = schema.get_columns()
1293
+ parameters = []
1294
+ for column_name in column_names:
1295
+ columns[column_name].injectable_properties(self.di)
1296
+ parameters.append(
1297
+ autodoc.request.JSONBody(
1298
+ columns[column_name].documentation(name=self.auto_case_column_name(column_name, True)),
1299
+ description=f"Set '{column_name}' for the {model_name}",
1300
+ required=columns[column_name].is_required,
1301
+ )
1302
+ )
1303
+ return parameters # type: ignore
1304
+
1305
+ def documentation_url_parameters(self) -> list[Parameter]:
1306
+ parameter_names = routing.extract_url_parameter_name_map(self.url.strip("/"))
1307
+ return [
1308
+ autodoc.request.URLPath(
1309
+ autodoc.schema.String(parameter_name),
1310
+ description=f"The {parameter_name}.",
1311
+ required=True,
1312
+ )
1313
+ for parameter_name in parameter_names.keys()
1314
+ ]