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