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