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