clear-skies 1.22.10__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.
- clear_skies-2.0.23.dist-info/METADATA +76 -0
- clear_skies-2.0.23.dist-info/RECORD +265 -0
- {clear_skies-1.22.10.dist-info → clear_skies-2.0.23.dist-info}/WHEEL +1 -1
- clearskies/__init__.py +37 -21
- clearskies/action.py +7 -0
- clearskies/authentication/__init__.py +8 -39
- clearskies/authentication/authentication.py +44 -0
- clearskies/authentication/authorization.py +14 -8
- clearskies/authentication/authorization_pass_through.py +14 -10
- clearskies/authentication/jwks.py +135 -58
- clearskies/authentication/public.py +3 -26
- clearskies/authentication/secret_bearer.py +515 -44
- clearskies/autodoc/formats/oai3_json/__init__.py +2 -2
- clearskies/autodoc/formats/oai3_json/oai3_json.py +11 -9
- clearskies/autodoc/formats/oai3_json/parameter.py +6 -3
- clearskies/autodoc/formats/oai3_json/request.py +7 -5
- clearskies/autodoc/formats/oai3_json/response.py +7 -4
- clearskies/autodoc/formats/oai3_json/schema/object.py +10 -1
- clearskies/autodoc/request/__init__.py +2 -0
- clearskies/autodoc/request/header.py +4 -6
- clearskies/autodoc/request/json_body.py +4 -6
- clearskies/autodoc/request/parameter.py +8 -0
- clearskies/autodoc/request/request.py +16 -4
- clearskies/autodoc/request/url_parameter.py +4 -6
- clearskies/autodoc/request/url_path.py +4 -6
- clearskies/autodoc/schema/__init__.py +4 -2
- clearskies/autodoc/schema/array.py +5 -6
- clearskies/autodoc/schema/boolean.py +4 -10
- clearskies/autodoc/schema/date.py +0 -3
- clearskies/autodoc/schema/datetime.py +1 -4
- clearskies/autodoc/schema/double.py +0 -3
- clearskies/autodoc/schema/enum.py +4 -2
- clearskies/autodoc/schema/integer.py +4 -9
- clearskies/autodoc/schema/long.py +0 -3
- clearskies/autodoc/schema/number.py +4 -9
- clearskies/autodoc/schema/object.py +5 -7
- clearskies/autodoc/schema/password.py +0 -3
- clearskies/autodoc/schema/schema.py +11 -0
- clearskies/autodoc/schema/string.py +4 -10
- clearskies/backends/__init__.py +55 -20
- clearskies/backends/api_backend.py +1118 -280
- clearskies/backends/backend.py +54 -85
- clearskies/backends/cursor_backend.py +246 -191
- clearskies/backends/memory_backend.py +514 -208
- clearskies/backends/secrets_backend.py +68 -31
- clearskies/column.py +1221 -0
- clearskies/columns/__init__.py +71 -0
- clearskies/columns/audit.py +306 -0
- clearskies/columns/belongs_to_id.py +478 -0
- clearskies/columns/belongs_to_model.py +129 -0
- clearskies/columns/belongs_to_self.py +109 -0
- clearskies/columns/boolean.py +110 -0
- clearskies/columns/category_tree.py +273 -0
- clearskies/columns/category_tree_ancestors.py +51 -0
- clearskies/columns/category_tree_children.py +126 -0
- clearskies/columns/category_tree_descendants.py +48 -0
- clearskies/columns/created.py +92 -0
- clearskies/columns/created_by_authorization_data.py +114 -0
- clearskies/columns/created_by_header.py +103 -0
- clearskies/columns/created_by_ip.py +90 -0
- clearskies/columns/created_by_routing_data.py +102 -0
- clearskies/columns/created_by_user_agent.py +89 -0
- clearskies/columns/date.py +232 -0
- clearskies/columns/datetime.py +284 -0
- clearskies/columns/email.py +78 -0
- clearskies/columns/float.py +149 -0
- clearskies/columns/has_many.py +529 -0
- clearskies/columns/has_many_self.py +62 -0
- clearskies/columns/has_one.py +21 -0
- clearskies/columns/integer.py +158 -0
- clearskies/columns/json.py +126 -0
- clearskies/columns/many_to_many_ids.py +335 -0
- clearskies/columns/many_to_many_ids_with_data.py +274 -0
- clearskies/columns/many_to_many_models.py +156 -0
- clearskies/columns/many_to_many_pivots.py +132 -0
- clearskies/columns/phone.py +162 -0
- clearskies/columns/select.py +95 -0
- clearskies/columns/string.py +102 -0
- clearskies/columns/timestamp.py +164 -0
- clearskies/columns/updated.py +107 -0
- clearskies/columns/uuid.py +83 -0
- clearskies/configs/README.md +105 -0
- clearskies/configs/__init__.py +170 -0
- clearskies/configs/actions.py +43 -0
- clearskies/configs/any.py +15 -0
- clearskies/configs/any_dict.py +24 -0
- clearskies/configs/any_dict_or_callable.py +25 -0
- clearskies/configs/authentication.py +23 -0
- clearskies/configs/authorization.py +23 -0
- clearskies/configs/boolean.py +18 -0
- clearskies/configs/boolean_or_callable.py +20 -0
- clearskies/configs/callable_config.py +20 -0
- clearskies/configs/columns.py +34 -0
- clearskies/configs/conditions.py +30 -0
- clearskies/configs/config.py +26 -0
- clearskies/configs/datetime.py +20 -0
- clearskies/configs/datetime_or_callable.py +21 -0
- clearskies/configs/email.py +10 -0
- clearskies/configs/email_list.py +17 -0
- clearskies/configs/email_list_or_callable.py +17 -0
- clearskies/configs/email_or_email_list_or_callable.py +59 -0
- clearskies/configs/endpoint.py +23 -0
- clearskies/configs/endpoint_list.py +29 -0
- clearskies/configs/float.py +18 -0
- clearskies/configs/float_or_callable.py +20 -0
- clearskies/configs/headers.py +28 -0
- clearskies/configs/integer.py +18 -0
- clearskies/configs/integer_or_callable.py +20 -0
- clearskies/configs/joins.py +30 -0
- clearskies/configs/list_any_dict.py +32 -0
- clearskies/configs/list_any_dict_or_callable.py +33 -0
- clearskies/configs/model_class.py +35 -0
- clearskies/configs/model_column.py +67 -0
- clearskies/configs/model_columns.py +58 -0
- clearskies/configs/model_destination_name.py +26 -0
- clearskies/configs/model_to_id_column.py +45 -0
- clearskies/configs/readable_model_column.py +11 -0
- clearskies/configs/readable_model_columns.py +11 -0
- clearskies/configs/schema.py +23 -0
- clearskies/configs/searchable_model_columns.py +11 -0
- clearskies/configs/security_headers.py +39 -0
- clearskies/configs/select.py +28 -0
- clearskies/configs/select_list.py +49 -0
- clearskies/configs/string.py +31 -0
- clearskies/configs/string_dict.py +34 -0
- clearskies/configs/string_list.py +47 -0
- clearskies/configs/string_list_or_callable.py +48 -0
- clearskies/configs/string_or_callable.py +18 -0
- clearskies/configs/timedelta.py +20 -0
- clearskies/configs/timezone.py +20 -0
- clearskies/configs/url.py +25 -0
- clearskies/configs/validators.py +45 -0
- clearskies/configs/writeable_model_column.py +11 -0
- clearskies/configs/writeable_model_columns.py +11 -0
- clearskies/configurable.py +78 -0
- clearskies/contexts/__init__.py +8 -8
- clearskies/contexts/cli.py +129 -43
- clearskies/contexts/context.py +93 -56
- clearskies/contexts/wsgi.py +79 -33
- clearskies/contexts/wsgi_ref.py +87 -0
- clearskies/cursors/__init__.py +7 -0
- clearskies/cursors/cursor.py +166 -0
- clearskies/cursors/from_environment/__init__.py +5 -0
- clearskies/cursors/from_environment/mysql.py +51 -0
- clearskies/cursors/from_environment/postgresql.py +49 -0
- clearskies/cursors/from_environment/sqlite.py +35 -0
- clearskies/cursors/mysql.py +61 -0
- clearskies/cursors/postgresql.py +61 -0
- clearskies/cursors/sqlite.py +62 -0
- clearskies/decorators.py +33 -0
- clearskies/decorators.pyi +10 -0
- clearskies/di/__init__.py +11 -7
- clearskies/di/additional_config.py +115 -4
- clearskies/di/additional_config_auto_import.py +12 -0
- clearskies/di/di.py +714 -125
- clearskies/di/inject/__init__.py +23 -0
- clearskies/di/inject/akeyless_sdk.py +16 -0
- clearskies/di/inject/by_class.py +24 -0
- clearskies/di/inject/by_name.py +22 -0
- clearskies/di/inject/di.py +16 -0
- clearskies/di/inject/environment.py +15 -0
- clearskies/di/inject/input_output.py +19 -0
- clearskies/di/inject/now.py +16 -0
- clearskies/di/inject/requests.py +16 -0
- clearskies/di/inject/secrets.py +15 -0
- clearskies/di/inject/utcnow.py +16 -0
- clearskies/di/inject/uuid.py +16 -0
- clearskies/di/injectable.py +32 -0
- clearskies/di/injectable_properties.py +131 -0
- clearskies/end.py +219 -0
- clearskies/endpoint.py +1303 -0
- clearskies/endpoint_group.py +333 -0
- clearskies/endpoints/__init__.py +25 -0
- clearskies/endpoints/advanced_search.py +519 -0
- clearskies/endpoints/callable.py +382 -0
- clearskies/endpoints/create.py +201 -0
- clearskies/endpoints/delete.py +133 -0
- clearskies/endpoints/get.py +267 -0
- clearskies/endpoints/health_check.py +181 -0
- clearskies/endpoints/list.py +567 -0
- clearskies/endpoints/restful_api.py +417 -0
- clearskies/endpoints/schema.py +185 -0
- clearskies/endpoints/simple_search.py +279 -0
- clearskies/endpoints/update.py +188 -0
- clearskies/environment.py +7 -3
- clearskies/exceptions/__init__.py +19 -0
- clearskies/{handlers/exceptions/input_error.py → exceptions/input_errors.py} +1 -1
- clearskies/exceptions/missing_dependency.py +2 -0
- clearskies/exceptions/moved_permanently.py +3 -0
- clearskies/exceptions/moved_temporarily.py +3 -0
- clearskies/functional/__init__.py +2 -2
- clearskies/functional/json.py +47 -0
- clearskies/functional/routing.py +92 -0
- clearskies/functional/string.py +19 -11
- clearskies/functional/validations.py +61 -9
- clearskies/input_outputs/__init__.py +9 -7
- clearskies/input_outputs/cli.py +135 -160
- clearskies/input_outputs/exceptions/__init__.py +6 -1
- clearskies/input_outputs/headers.py +54 -0
- clearskies/input_outputs/input_output.py +77 -123
- clearskies/input_outputs/programmatic.py +62 -0
- clearskies/input_outputs/wsgi.py +36 -48
- clearskies/model.py +1874 -193
- clearskies/query/__init__.py +12 -0
- clearskies/query/condition.py +228 -0
- clearskies/query/join.py +136 -0
- clearskies/query/query.py +193 -0
- clearskies/query/sort.py +27 -0
- clearskies/schema.py +82 -0
- clearskies/secrets/__init__.py +4 -31
- clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +15 -4
- clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +11 -5
- clearskies/secrets/akeyless.py +421 -155
- clearskies/secrets/exceptions/__init__.py +7 -1
- clearskies/secrets/exceptions/not_found_error.py +2 -0
- clearskies/secrets/exceptions/permissions_error.py +2 -0
- clearskies/secrets/secrets.py +12 -11
- clearskies/security_header.py +17 -0
- clearskies/security_headers/__init__.py +8 -8
- clearskies/security_headers/cache_control.py +47 -109
- clearskies/security_headers/cors.py +38 -92
- clearskies/security_headers/csp.py +76 -150
- clearskies/security_headers/hsts.py +14 -15
- clearskies/typing.py +11 -0
- clearskies/validator.py +36 -0
- clearskies/validators/__init__.py +33 -0
- clearskies/validators/after_column.py +61 -0
- clearskies/validators/before_column.py +15 -0
- clearskies/validators/in_the_future.py +29 -0
- clearskies/validators/in_the_future_at_least.py +13 -0
- clearskies/validators/in_the_future_at_most.py +12 -0
- clearskies/validators/in_the_past.py +29 -0
- clearskies/validators/in_the_past_at_least.py +12 -0
- clearskies/validators/in_the_past_at_most.py +12 -0
- clearskies/validators/maximum_length.py +25 -0
- clearskies/validators/maximum_value.py +28 -0
- clearskies/validators/minimum_length.py +25 -0
- clearskies/validators/minimum_value.py +28 -0
- clearskies/{input_requirements → validators}/required.py +18 -9
- clearskies/validators/timedelta.py +58 -0
- clearskies/validators/unique.py +28 -0
- clear_skies-1.22.10.dist-info/METADATA +0 -47
- clear_skies-1.22.10.dist-info/RECORD +0 -213
- clearskies/application.py +0 -29
- clearskies/authentication/auth0_jwks.py +0 -118
- clearskies/authentication/auth_exception.py +0 -2
- clearskies/authentication/jwks_jwcrypto.py +0 -51
- clearskies/backends/api_get_only_backend.py +0 -48
- clearskies/backends/example_backend.py +0 -43
- clearskies/backends/file_backend.py +0 -48
- clearskies/backends/json_backend.py +0 -7
- clearskies/backends/restful_api_advanced_search_backend.py +0 -103
- clearskies/binding_config.py +0 -16
- clearskies/column_types/__init__.py +0 -203
- clearskies/column_types/audit.py +0 -249
- clearskies/column_types/belongs_to.py +0 -271
- clearskies/column_types/boolean.py +0 -60
- clearskies/column_types/category_tree.py +0 -304
- clearskies/column_types/column.py +0 -373
- clearskies/column_types/created.py +0 -26
- clearskies/column_types/created_by_authorization_data.py +0 -26
- clearskies/column_types/created_by_header.py +0 -24
- clearskies/column_types/created_by_ip.py +0 -17
- clearskies/column_types/created_by_routing_data.py +0 -25
- clearskies/column_types/created_by_user_agent.py +0 -17
- clearskies/column_types/created_micro.py +0 -26
- clearskies/column_types/datetime.py +0 -109
- clearskies/column_types/datetime_micro.py +0 -13
- clearskies/column_types/email.py +0 -18
- clearskies/column_types/float.py +0 -43
- clearskies/column_types/has_many.py +0 -179
- clearskies/column_types/has_one.py +0 -58
- clearskies/column_types/integer.py +0 -41
- clearskies/column_types/json.py +0 -25
- clearskies/column_types/many_to_many.py +0 -278
- clearskies/column_types/many_to_many_with_data.py +0 -162
- clearskies/column_types/phone.py +0 -48
- clearskies/column_types/select.py +0 -11
- clearskies/column_types/string.py +0 -24
- clearskies/column_types/timestamp.py +0 -73
- clearskies/column_types/updated.py +0 -26
- clearskies/column_types/updated_micro.py +0 -26
- clearskies/column_types/uuid.py +0 -25
- clearskies/columns.py +0 -123
- clearskies/condition_parser.py +0 -172
- clearskies/contexts/build_context.py +0 -54
- clearskies/contexts/convert_to_application.py +0 -190
- clearskies/contexts/extract_handler.py +0 -37
- clearskies/contexts/test.py +0 -94
- clearskies/decorators/__init__.py +0 -39
- clearskies/decorators/auth0_jwks.py +0 -22
- clearskies/decorators/authorization.py +0 -10
- clearskies/decorators/binding_classes.py +0 -9
- clearskies/decorators/binding_modules.py +0 -9
- clearskies/decorators/bindings.py +0 -9
- clearskies/decorators/create.py +0 -10
- clearskies/decorators/delete.py +0 -10
- clearskies/decorators/docs.py +0 -14
- clearskies/decorators/get.py +0 -10
- clearskies/decorators/jwks.py +0 -26
- clearskies/decorators/merge.py +0 -124
- clearskies/decorators/patch.py +0 -10
- clearskies/decorators/post.py +0 -10
- clearskies/decorators/public.py +0 -11
- clearskies/decorators/response_headers.py +0 -10
- clearskies/decorators/return_raw_response.py +0 -9
- clearskies/decorators/schema.py +0 -10
- clearskies/decorators/secret_bearer.py +0 -24
- clearskies/decorators/security_headers.py +0 -10
- clearskies/di/standard_dependencies.py +0 -151
- clearskies/di/test_module/__init__.py +0 -6
- clearskies/di/test_module/another_module/__init__.py +0 -2
- clearskies/di/test_module/module_class.py +0 -5
- clearskies/handlers/__init__.py +0 -41
- clearskies/handlers/advanced_search.py +0 -271
- clearskies/handlers/base.py +0 -479
- clearskies/handlers/callable.py +0 -191
- clearskies/handlers/create.py +0 -35
- clearskies/handlers/crud_by_method.py +0 -18
- clearskies/handlers/database_connector.py +0 -32
- clearskies/handlers/delete.py +0 -61
- clearskies/handlers/exceptions/__init__.py +0 -5
- clearskies/handlers/exceptions/not_found.py +0 -3
- clearskies/handlers/get.py +0 -156
- clearskies/handlers/health_check.py +0 -59
- clearskies/handlers/input_processing.py +0 -79
- clearskies/handlers/list.py +0 -530
- clearskies/handlers/mygrations.py +0 -82
- clearskies/handlers/request_method_routing.py +0 -47
- clearskies/handlers/restful_api.py +0 -218
- clearskies/handlers/routing.py +0 -62
- clearskies/handlers/schema_helper.py +0 -128
- clearskies/handlers/simple_routing.py +0 -206
- clearskies/handlers/simple_routing_route.py +0 -192
- clearskies/handlers/simple_search.py +0 -136
- clearskies/handlers/update.py +0 -96
- clearskies/handlers/write.py +0 -193
- clearskies/input_requirements/__init__.py +0 -78
- clearskies/input_requirements/after.py +0 -36
- clearskies/input_requirements/before.py +0 -36
- clearskies/input_requirements/in_the_future_at_least.py +0 -19
- clearskies/input_requirements/in_the_future_at_most.py +0 -19
- clearskies/input_requirements/in_the_past_at_least.py +0 -19
- clearskies/input_requirements/in_the_past_at_most.py +0 -19
- clearskies/input_requirements/maximum_length.py +0 -19
- clearskies/input_requirements/maximum_value.py +0 -19
- clearskies/input_requirements/minimum_length.py +0 -22
- clearskies/input_requirements/minimum_value.py +0 -19
- clearskies/input_requirements/requirement.py +0 -25
- clearskies/input_requirements/time_delta.py +0 -38
- clearskies/input_requirements/unique.py +0 -18
- clearskies/mocks/__init__.py +0 -7
- clearskies/mocks/input_output.py +0 -124
- clearskies/mocks/models.py +0 -142
- clearskies/models.py +0 -350
- clearskies/security_headers/base.py +0 -12
- clearskies/tests/simple_api/models/__init__.py +0 -2
- clearskies/tests/simple_api/models/status.py +0 -23
- clearskies/tests/simple_api/models/user.py +0 -21
- clearskies/tests/simple_api/users_api.py +0 -64
- {clear_skies-1.22.10.dist-info → clear_skies-2.0.23.dist-info/licenses}/LICENSE +0 -0
- /clearskies/{contexts/bash.py → autodoc/py.typed} +0 -0
- /clearskies/{handlers/exceptions → exceptions}/authentication.py +0 -0
- /clearskies/{handlers/exceptions → exceptions}/authorization.py +0 -0
- /clearskies/{handlers/exceptions → exceptions}/client_error.py +0 -0
- /clearskies/{secrets/exceptions → exceptions}/not_found.py +0 -0
- /clearskies/{tests/__init__.py → input_outputs/py.typed} +0 -0
- /clearskies/{tests/simple_api/__init__.py → py.typed} +0 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
4
|
+
|
|
5
|
+
from clearskies import authentication, autodoc, decorators, exceptions
|
|
6
|
+
from clearskies.endpoints.list import List
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from clearskies import Column, Model, Schema, SecurityHeader, typing
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SimpleSearch(List):
|
|
13
|
+
"""
|
|
14
|
+
Create an endpoint that supports searching by exact values via url/JSON parameters.
|
|
15
|
+
|
|
16
|
+
This acts exactly like the list endpoint but additionally grants the client the ability to search records
|
|
17
|
+
via URL parameters or JSON POST body parameters. You just have to specify which columns are searchable.
|
|
18
|
+
|
|
19
|
+
In the following example we tell the `SimpleSearch` endpoint that we want it to return records from the
|
|
20
|
+
`Student` model, return `id`, `name`, and `grade` in the results, and allow the user to search by
|
|
21
|
+
`name` and `grade`. We also seed the memory backend with data so the endpoint has something to return:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import clearskies
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Student(clearskies.Model):
|
|
28
|
+
backend = clearskies.backends.MemoryBackend()
|
|
29
|
+
id_column_name = "id"
|
|
30
|
+
|
|
31
|
+
id = clearskies.columns.Uuid()
|
|
32
|
+
name = clearskies.columns.String()
|
|
33
|
+
grade = clearskies.columns.Integer()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
wsgi = clearskies.contexts.WsgiRef(
|
|
37
|
+
clearskies.endpoints.SimpleSearch(
|
|
38
|
+
Student,
|
|
39
|
+
readable_column_names=["id", "name", "grade"],
|
|
40
|
+
sortable_column_names=["name", "grade"],
|
|
41
|
+
searchable_column_names=["name", "grade"],
|
|
42
|
+
default_sort_column_name="name",
|
|
43
|
+
),
|
|
44
|
+
bindings={
|
|
45
|
+
"memory_backend_default_data": [
|
|
46
|
+
{
|
|
47
|
+
"model_class": Student,
|
|
48
|
+
"records": [
|
|
49
|
+
{"id": "1-2-3-4", "name": "Bob", "grade": 5},
|
|
50
|
+
{"id": "1-2-3-5", "name": "Jane", "grade": 3},
|
|
51
|
+
{"id": "1-2-3-6", "name": "Greg", "grade": 3},
|
|
52
|
+
{"id": "1-2-3-7", "name": "Bob", "grade": 2},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
wsgi()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Here is the basic operation of the endpoint itself, without any search parameters, in which case it behaves
|
|
62
|
+
identically to the list endpoint:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
$ curl 'http://localhost:8080' | jq
|
|
66
|
+
{
|
|
67
|
+
"status": "success",
|
|
68
|
+
"error": "",
|
|
69
|
+
"data": [
|
|
70
|
+
{
|
|
71
|
+
"id": "1-2-3-4",
|
|
72
|
+
"name": "Bob",
|
|
73
|
+
"grade": 5
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"id": "1-2-3-7",
|
|
77
|
+
"name": "Bob",
|
|
78
|
+
"grade": 2
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "1-2-3-6",
|
|
82
|
+
"name": "Greg",
|
|
83
|
+
"grade": 3
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"id": "1-2-3-5",
|
|
87
|
+
"name": "Jane",
|
|
88
|
+
"grade": 3
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
"pagination": {},
|
|
92
|
+
"input_errors": {}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
We can then search on name via the `name` URL parameter:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
$ curl 'http://localhost:8080?name=Bob' | jq
|
|
100
|
+
{
|
|
101
|
+
"status": "success",
|
|
102
|
+
"error": "",
|
|
103
|
+
"data": [
|
|
104
|
+
{
|
|
105
|
+
"id": "1-2-3-4",
|
|
106
|
+
"name": "Bob",
|
|
107
|
+
"grade": 5
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"id": "1-2-3-7",
|
|
111
|
+
"name": "Bob",
|
|
112
|
+
"grade": 2
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
"pagination": {},
|
|
116
|
+
"input_errors": {}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
and multiple search terms are allowed:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
$ curl 'http://localhost:8080?name=Bob&grade=2' | jq
|
|
124
|
+
{
|
|
125
|
+
"status": "success",
|
|
126
|
+
"error": "",
|
|
127
|
+
"data": [
|
|
128
|
+
{
|
|
129
|
+
"id": "1-2-3-7",
|
|
130
|
+
"name": "Bob",
|
|
131
|
+
"grade": 2
|
|
132
|
+
}
|
|
133
|
+
],
|
|
134
|
+
"pagination": {},
|
|
135
|
+
"input_errors": {}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Pagination and sorting work just like with the list endpoint:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
$ curl 'http://localhost:8080?sort=grade&direction=desc&limit=2' | jq
|
|
143
|
+
{
|
|
144
|
+
"status": "success",
|
|
145
|
+
"error": "",
|
|
146
|
+
"data": [
|
|
147
|
+
{
|
|
148
|
+
"id": "1-2-3-4",
|
|
149
|
+
"name": "Bob",
|
|
150
|
+
"grade": 5
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"id": "1-2-3-5",
|
|
154
|
+
"name": "Jane",
|
|
155
|
+
"grade": 3
|
|
156
|
+
}
|
|
157
|
+
],
|
|
158
|
+
"pagination": {
|
|
159
|
+
"number_results": 4,
|
|
160
|
+
"limit": 2,
|
|
161
|
+
"next_page": {
|
|
162
|
+
"start": 2
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
"input_errors": {}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
$ curl 'http://localhost:8080?sort=grade&direction=desc&limit=2&start=2' | jq
|
|
169
|
+
{
|
|
170
|
+
"status": "success",
|
|
171
|
+
"error": "",
|
|
172
|
+
"data": [
|
|
173
|
+
{
|
|
174
|
+
"id": "1-2-3-6",
|
|
175
|
+
"name": "Greg",
|
|
176
|
+
"grade": 3
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"id": "1-2-3-7",
|
|
180
|
+
"name": "Bob",
|
|
181
|
+
"grade": 2
|
|
182
|
+
}
|
|
183
|
+
],
|
|
184
|
+
"pagination": {},
|
|
185
|
+
"input_errors": {}
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
@decorators.parameters_to_properties
|
|
191
|
+
def __init__(
|
|
192
|
+
self,
|
|
193
|
+
model_class: type[Model],
|
|
194
|
+
readable_column_names: list[str],
|
|
195
|
+
sortable_column_names: list[str],
|
|
196
|
+
searchable_column_names: list[str],
|
|
197
|
+
default_sort_column_name: str,
|
|
198
|
+
default_sort_direction: str = "ASC",
|
|
199
|
+
default_limit: int = 50,
|
|
200
|
+
maximum_limit: int = 200,
|
|
201
|
+
where: typing.condition | list[typing.condition] = [],
|
|
202
|
+
joins: typing.join | list[typing.join] = [],
|
|
203
|
+
url: str = "",
|
|
204
|
+
request_methods: list[str] = ["GET", "POST", "QUERY"],
|
|
205
|
+
response_headers: list[str | Callable[..., list[str]]] = [],
|
|
206
|
+
output_map: Callable[..., dict[str, Any]] | None = None,
|
|
207
|
+
output_schema: Schema | None = None,
|
|
208
|
+
column_overrides: dict[str, Column] = {},
|
|
209
|
+
internal_casing: str = "snake_case",
|
|
210
|
+
external_casing: str = "snake_case",
|
|
211
|
+
security_headers: list[SecurityHeader] = [],
|
|
212
|
+
description: str = "",
|
|
213
|
+
authentication: authentication.Authentication = authentication.Public(),
|
|
214
|
+
authorization: authentication.Authorization = authentication.Authorization(),
|
|
215
|
+
):
|
|
216
|
+
self.request_methods = request_methods
|
|
217
|
+
|
|
218
|
+
# 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
|
|
219
|
+
# just stores them in parameters, which we have already done. However, the parent does do some extra initialization stuff that we need,
|
|
220
|
+
# which is why we have to call the parent.
|
|
221
|
+
super().__init__(model_class, readable_column_names, sortable_column_names, default_sort_column_name)
|
|
222
|
+
|
|
223
|
+
def check_search_in_request_data(self, request_data: dict[str, Any], query_parameters: dict[str, Any]) -> None:
|
|
224
|
+
for input_source_label, input_data in [("request body", request_data), ("URL data", query_parameters)]:
|
|
225
|
+
for column_name, value in input_data.items():
|
|
226
|
+
if column_name in self.allowed_request_keys and column_name not in self.searchable_column_names:
|
|
227
|
+
continue
|
|
228
|
+
if column_name not in self.searchable_column_names:
|
|
229
|
+
raise exceptions.ClientError(
|
|
230
|
+
f"Invalid request parameter found in {input_source_label}: '{column_name}'"
|
|
231
|
+
)
|
|
232
|
+
[relationship_column_name, final_column_name] = self.unpack_column_name_with_relationship(column_name)
|
|
233
|
+
column_to_check = relationship_column_name if relationship_column_name else final_column_name
|
|
234
|
+
value_error = self.searchable_columns[column_to_check].check_search_value(
|
|
235
|
+
value, relationship_reference=final_column_name
|
|
236
|
+
)
|
|
237
|
+
if value_error:
|
|
238
|
+
raise exceptions.InputErrors({column_name: value_error})
|
|
239
|
+
|
|
240
|
+
def configure_model_from_request_data(
|
|
241
|
+
self,
|
|
242
|
+
model: Model,
|
|
243
|
+
request_data: dict[str, Any],
|
|
244
|
+
query_parameters: dict[str, Any],
|
|
245
|
+
pagination_data: dict[str, Any],
|
|
246
|
+
) -> Model:
|
|
247
|
+
model = super().configure_model_from_request_data(
|
|
248
|
+
model,
|
|
249
|
+
request_data,
|
|
250
|
+
query_parameters,
|
|
251
|
+
pagination_data,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
for input_source in [request_data, query_parameters]:
|
|
255
|
+
for column_name, value in input_source.items():
|
|
256
|
+
if column_name not in self.searchable_column_names:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
model = self.add_join(column_name, model)
|
|
260
|
+
[relationship_column_name, column_name] = self.unpack_column_name_with_relationship(column_name)
|
|
261
|
+
if relationship_column_name:
|
|
262
|
+
self.columns[relationship_column_name].add_search(model, value, relationship_reference=column_name)
|
|
263
|
+
else:
|
|
264
|
+
model = self.columns[column_name].add_search(model, value, operator="=")
|
|
265
|
+
|
|
266
|
+
return model
|
|
267
|
+
|
|
268
|
+
def documentation_url_search_parameters(self) -> list[autodoc.request.Parameter]:
|
|
269
|
+
docs = []
|
|
270
|
+
for column in self.searchable_columns.values():
|
|
271
|
+
column_doc = column.documentation()[0]
|
|
272
|
+
column_doc.name = self.auto_case_internal_column_name(column_doc.name)
|
|
273
|
+
docs.append(
|
|
274
|
+
autodoc.request.URLParameter(
|
|
275
|
+
column_doc,
|
|
276
|
+
description=f"Search by {column_doc.name} (via exact match)",
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
return docs # type: ignore
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
4
|
+
|
|
5
|
+
from clearskies import authentication, autodoc, decorators, exceptions
|
|
6
|
+
from clearskies.endpoints.get import Get
|
|
7
|
+
from clearskies.functional import string
|
|
8
|
+
from clearskies.input_outputs import InputOutput
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from clearskies import Column, Model, Schema, SecurityHeader, typing
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Update(Get):
|
|
15
|
+
"""
|
|
16
|
+
An endpoint to update a record.
|
|
17
|
+
|
|
18
|
+
This endpoint handles update operations. As with the `Get` endpoint, it will lookup the record by taking
|
|
19
|
+
the record id (or any other unique column you specify) out of the URL and then will fetch that record
|
|
20
|
+
using the model class. Then, it will use the model and list of writeable column names to validate the
|
|
21
|
+
incoming user input. The default request method is `PATCH`. If everything checks out, it will then
|
|
22
|
+
update the record.
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import clearskies
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class User(clearskies.Model):
|
|
29
|
+
id_column_name = "id"
|
|
30
|
+
backend = clearskies.backends.MemoryBackend()
|
|
31
|
+
id = clearskies.columns.Uuid()
|
|
32
|
+
name = clearskies.columns.String()
|
|
33
|
+
username = clearskies.columns.String(validators=[clearskies.validators.Required()])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
wsgi = clearskies.contexts.WsgiRef(
|
|
37
|
+
clearskies.endpoints.Update(
|
|
38
|
+
model_class=User,
|
|
39
|
+
url="/{id}",
|
|
40
|
+
readable_column_names=["id", "name", "username"],
|
|
41
|
+
writeable_column_names=["name", "username"],
|
|
42
|
+
),
|
|
43
|
+
bindings={
|
|
44
|
+
"memory_backend_default_data": [
|
|
45
|
+
{
|
|
46
|
+
"model_class": User,
|
|
47
|
+
"records": [
|
|
48
|
+
{"id": "1-2-3-4", "name": "Bob Brown", "username": "bobbrown"},
|
|
49
|
+
{"id": "1-2-3-5", "name": "Jane Doe", "username": "janedoe"},
|
|
50
|
+
{"id": "1-2-3-6", "name": "Greg", "username": "greg"},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
wsgi()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
And when invoked:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
$ curl 'http://localhost:8080/1-2-3-4' -X PATCH -d '{"name": "Bobby Brown", "username": "bobbybrown"}' | jq
|
|
63
|
+
{
|
|
64
|
+
"status": "success",
|
|
65
|
+
"error": "",
|
|
66
|
+
"data": {
|
|
67
|
+
"id": "1-2-3-4",
|
|
68
|
+
"name": "Bobby Brown",
|
|
69
|
+
"username": "bobbybrown"
|
|
70
|
+
},
|
|
71
|
+
"pagination": {},
|
|
72
|
+
"input_errors": {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
$ curl 'http://localhost:8080/1-2-3-5' -X PATCH -d '{"name": 12345, "username": ""}' | jq
|
|
76
|
+
{
|
|
77
|
+
"status": "input_errors",
|
|
78
|
+
"error": "",
|
|
79
|
+
"data": [],
|
|
80
|
+
"pagination": {},
|
|
81
|
+
"input_errors": {
|
|
82
|
+
"name": "value should be a string",
|
|
83
|
+
"username": "'username' is required."
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
@decorators.parameters_to_properties
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
model_class: type[Model],
|
|
92
|
+
url: str,
|
|
93
|
+
writeable_column_names: list[str],
|
|
94
|
+
readable_column_names: list[str],
|
|
95
|
+
record_lookup_column_name: str | None = None,
|
|
96
|
+
input_validation_callable: Callable | None = None,
|
|
97
|
+
request_methods: list[str] = ["PATCH"],
|
|
98
|
+
response_headers: list[str | Callable[..., list[str]]] = [],
|
|
99
|
+
output_map: Callable[..., dict[str, Any]] | None = None,
|
|
100
|
+
output_schema: Schema | None = None,
|
|
101
|
+
column_overrides: dict[str, Column] = {},
|
|
102
|
+
internal_casing: str = "snake_case",
|
|
103
|
+
external_casing: str = "snake_case",
|
|
104
|
+
security_headers: list[SecurityHeader] = [],
|
|
105
|
+
description: str = "",
|
|
106
|
+
where: typing.condition | list[typing.condition] = [],
|
|
107
|
+
joins: typing.join | list[typing.join] = [],
|
|
108
|
+
authentication: authentication.Authentication = authentication.Public(),
|
|
109
|
+
authorization: authentication.Authorization = authentication.Authorization(),
|
|
110
|
+
):
|
|
111
|
+
# see comment in clearskies.endpoints.Create.__init__
|
|
112
|
+
self.request_methods = request_methods
|
|
113
|
+
|
|
114
|
+
# 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
|
|
115
|
+
# just stores them in parameters, which we have already done. However, the parent does do some extra initialization stuff that we need,
|
|
116
|
+
# which is why we have to call the parent.
|
|
117
|
+
super().__init__(model_class, url, readable_column_names)
|
|
118
|
+
|
|
119
|
+
def handle(self, input_output: InputOutput) -> Any:
|
|
120
|
+
request_data = self.get_request_data(input_output)
|
|
121
|
+
if not request_data and input_output.has_body():
|
|
122
|
+
raise exceptions.ClientError("Request body was not valid JSON")
|
|
123
|
+
model = self.fetch_model(input_output)
|
|
124
|
+
self.validate_input_against_schema(request_data, input_output, model)
|
|
125
|
+
model.save(request_data)
|
|
126
|
+
return self.success(input_output, self.model_as_json(model, input_output))
|
|
127
|
+
|
|
128
|
+
def documentation(self) -> list[autodoc.request.Request]:
|
|
129
|
+
output_schema = self.model_class
|
|
130
|
+
nice_model = string.camel_case_to_words(output_schema.__name__)
|
|
131
|
+
|
|
132
|
+
schema_model_name = string.camel_case_to_snake_case(output_schema.__name__)
|
|
133
|
+
output_data_schema = self.documentation_data_schema(output_schema, self.readable_column_names)
|
|
134
|
+
output_autodoc = (
|
|
135
|
+
autodoc.schema.Object(
|
|
136
|
+
self.auto_case_internal_column_name("data"), children=output_data_schema, model_name=schema_model_name
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
authentication = self.authentication
|
|
141
|
+
# Many swagger UIs will only allow one response per status code, and we use the same status code (200)
|
|
142
|
+
# for both a success response and an input error response. This could be fixed by changing the status
|
|
143
|
+
# code for input error responses, but there's not actually a great HTTP status code for that, so :shrug:
|
|
144
|
+
# standard_error_responses = [self.documentation_input_error_response()]
|
|
145
|
+
standard_error_responses = []
|
|
146
|
+
if not getattr(authentication, "is_public", False):
|
|
147
|
+
standard_error_responses.append(self.documentation_access_denied_response())
|
|
148
|
+
if getattr(authentication, "can_authorize", False):
|
|
149
|
+
standard_error_responses.append(self.documentation_unauthorized_response())
|
|
150
|
+
|
|
151
|
+
return [
|
|
152
|
+
autodoc.request.Request(
|
|
153
|
+
self.description,
|
|
154
|
+
[
|
|
155
|
+
self.documentation_success_response(
|
|
156
|
+
output_autodoc, # type: ignore
|
|
157
|
+
description=self.description,
|
|
158
|
+
),
|
|
159
|
+
*standard_error_responses,
|
|
160
|
+
self.documentation_generic_error_response(),
|
|
161
|
+
],
|
|
162
|
+
relative_path=self.url,
|
|
163
|
+
request_methods=self.request_methods,
|
|
164
|
+
parameters=[
|
|
165
|
+
*self.documentation_request_parameters(),
|
|
166
|
+
*self.documentation_url_parameters(),
|
|
167
|
+
],
|
|
168
|
+
root_properties={
|
|
169
|
+
"security": self.documentation_request_security(),
|
|
170
|
+
},
|
|
171
|
+
),
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
def documentation_request_parameters(self) -> list[autodoc.request.Parameter]:
|
|
175
|
+
return [
|
|
176
|
+
*self.standard_json_request_parameters(self.model_class),
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
def documentation_models(self) -> dict[str, autodoc.schema.Schema]:
|
|
180
|
+
output_schema = self.output_schema if self.output_schema else self.model_class
|
|
181
|
+
schema_model_name = string.camel_case_to_snake_case(output_schema.__name__)
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
schema_model_name: autodoc.schema.Object(
|
|
185
|
+
self.auto_case_internal_column_name("data"),
|
|
186
|
+
children=self.documentation_data_schema(output_schema, self.readable_column_names),
|
|
187
|
+
),
|
|
188
|
+
}
|
clearskies/environment.py
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import os.path
|
|
4
|
+
from typing import Any
|
|
2
5
|
|
|
3
6
|
|
|
4
7
|
class Environment:
|
|
@@ -14,8 +17,9 @@ class Environment:
|
|
|
14
17
|
is assumed to be a string.
|
|
15
18
|
"""
|
|
16
19
|
|
|
17
|
-
_env_file_config = None
|
|
18
|
-
_resolved_values =
|
|
20
|
+
_env_file_config: dict[str, Any] = None # type: ignore
|
|
21
|
+
_resolved_values: dict[str, Any] = {}
|
|
22
|
+
os_environ: dict[str, Any] = {}
|
|
19
23
|
|
|
20
24
|
def __init__(self, env_file_path, os_environ, secrets):
|
|
21
25
|
self._env_file_path = env_file_path
|
|
@@ -23,7 +27,7 @@ class Environment:
|
|
|
23
27
|
self.secrets = secrets
|
|
24
28
|
self._resolved_values = {}
|
|
25
29
|
|
|
26
|
-
def get(self, name, silent=False):
|
|
30
|
+
def get(self, name, silent=False) -> Any:
|
|
27
31
|
self._load_env_file()
|
|
28
32
|
if name in self.os_environ:
|
|
29
33
|
return self.resolve_value(self.os_environ[name])
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from clearskies.exceptions.authentication import Authentication
|
|
2
|
+
from clearskies.exceptions.authorization import Authorization
|
|
3
|
+
from clearskies.exceptions.client_error import ClientError
|
|
4
|
+
from clearskies.exceptions.input_errors import InputErrors
|
|
5
|
+
from clearskies.exceptions.missing_dependency import MissingDependency
|
|
6
|
+
from clearskies.exceptions.moved_permanently import MovedPermanently
|
|
7
|
+
from clearskies.exceptions.moved_temporarily import MovedTemporarily
|
|
8
|
+
from clearskies.exceptions.not_found import NotFound
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Authentication",
|
|
12
|
+
"Authorization",
|
|
13
|
+
"ClientError",
|
|
14
|
+
"InputErrors",
|
|
15
|
+
"MissingDependency",
|
|
16
|
+
"MovedPermanently",
|
|
17
|
+
"MovedTemporarily",
|
|
18
|
+
"NotFound",
|
|
19
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from typing import Any, cast
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_nested_attribute(data: dict[str, Any] | str, attr_path: str) -> Any:
|
|
5
|
+
"""
|
|
6
|
+
Extract a nested attribute from JSON data using dot notation.
|
|
7
|
+
|
|
8
|
+
This function navigates through a nested JSON structure using a dot-separated path
|
|
9
|
+
to retrieve a specific attribute. If the input is a string, it will attempt to parse
|
|
10
|
+
it as JSON first.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
```
|
|
14
|
+
data = {"database": {"credentials": {"username": "admin", "password": "secret"}}}
|
|
15
|
+
username = get_nested_attribute(data, "database.credentials.username")
|
|
16
|
+
# Returns "admin"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
data: The JSON data as a dictionary or a JSON string
|
|
21
|
+
attr_path: The path to the attribute using dot notation (e.g., "database.username")
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The value at the specified path
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ValueError: If the data cannot be parsed as JSON
|
|
28
|
+
KeyError: If the attribute path doesn't exist in the data
|
|
29
|
+
"""
|
|
30
|
+
keys = attr_path.split(".", 1)
|
|
31
|
+
if not isinstance(data, dict):
|
|
32
|
+
try:
|
|
33
|
+
import json
|
|
34
|
+
|
|
35
|
+
data = json.loads(data)
|
|
36
|
+
except Exception:
|
|
37
|
+
raise ValueError(f"Could not parse data as JSON to get attribute '{attr_path}'")
|
|
38
|
+
|
|
39
|
+
# At this point, we know data is a dictionary
|
|
40
|
+
data_dict = cast(dict[str, Any], data) # Help type checker understand data is a dict
|
|
41
|
+
|
|
42
|
+
if len(keys) == 1:
|
|
43
|
+
if keys[0] not in data_dict:
|
|
44
|
+
raise KeyError(f"Data does not contain attribute '{attr_path}'")
|
|
45
|
+
return data_dict[keys[0]]
|
|
46
|
+
|
|
47
|
+
return get_nested_attribute(data_dict[keys[0]], keys[1])
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def match_route(expected_route, incoming_route, allow_partial=False) -> tuple[bool, dict[str, str]]:
|
|
5
|
+
"""
|
|
6
|
+
Check if two routes match, and returns the routing data if so.
|
|
7
|
+
|
|
8
|
+
A partial match happens when the beginning of the incoming route matches the expected route. It's okay for the
|
|
9
|
+
incoming route to be longer because the routing system is hierarchical, so a partial match at the beginning
|
|
10
|
+
can work. e.g.:
|
|
11
|
+
|
|
12
|
+
Expected route: `/users`
|
|
13
|
+
Incoming route: `/users/orders/5`
|
|
14
|
+
|
|
15
|
+
But note that it must fully match all route segments, so this is never a match:
|
|
16
|
+
|
|
17
|
+
Expected route: `/user`
|
|
18
|
+
Incoming route: `/users/orders/5`
|
|
19
|
+
"""
|
|
20
|
+
expected_route = expected_route.strip("/")
|
|
21
|
+
incoming_route = incoming_route.strip("/")
|
|
22
|
+
|
|
23
|
+
expected_parts = expected_route.split("/")
|
|
24
|
+
incoming_parts = incoming_route.split("/")
|
|
25
|
+
|
|
26
|
+
# quick check: if there are less parts in the incoming route than the expected route, then we can't possibly match
|
|
27
|
+
if len(incoming_parts) < len(expected_parts):
|
|
28
|
+
return (False, {})
|
|
29
|
+
# ditto the opposite, if we can't do a partial match
|
|
30
|
+
if len(expected_parts) < len(incoming_parts) and not allow_partial:
|
|
31
|
+
return (False, {})
|
|
32
|
+
|
|
33
|
+
# if we got this far then we will do a more complete match, so let's find any routing parameters
|
|
34
|
+
routing_data = {}
|
|
35
|
+
routing_parameters = extract_url_parameter_name_map(expected_route)
|
|
36
|
+
# we want it backwards
|
|
37
|
+
routing_parameters_by_index = {value: key for (key, value) in routing_parameters.items()}
|
|
38
|
+
for index in range(len(expected_parts)):
|
|
39
|
+
if index in routing_parameters_by_index:
|
|
40
|
+
if not incoming_parts[index]:
|
|
41
|
+
return (False, {})
|
|
42
|
+
routing_data[routing_parameters_by_index[index]] = incoming_parts[index]
|
|
43
|
+
else:
|
|
44
|
+
if expected_parts[index] != incoming_parts[index]:
|
|
45
|
+
return (False, {})
|
|
46
|
+
|
|
47
|
+
return (True, routing_data)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def extract_url_parameter_name_map(url: str) -> dict[str, int]:
|
|
51
|
+
"""
|
|
52
|
+
Create a map to help match URLs with routing parameters.
|
|
53
|
+
|
|
54
|
+
Routing parameters are either brace enclosed or start with colons:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
print(
|
|
58
|
+
routing.extract_url_parameter_name_map("my/path/{some_parameter}/:other_parameter/more/paths")
|
|
59
|
+
)
|
|
60
|
+
# prints {"some_parameter": 2, "other_parameter": 3}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Note that leading and trailing slashes are stripped, so "/my/path/{id}" and "my/path/{id}" give identical
|
|
64
|
+
parameter maps: `{"id": 2}`
|
|
65
|
+
"""
|
|
66
|
+
parameter_name_map = {}
|
|
67
|
+
path_parts = url.strip("/").split("/")
|
|
68
|
+
for index, part in enumerate(path_parts):
|
|
69
|
+
if not part:
|
|
70
|
+
continue
|
|
71
|
+
if part[0] == ":":
|
|
72
|
+
match = re.match("^:(\\w[\\w\\d_]{0,})$", part)
|
|
73
|
+
else:
|
|
74
|
+
if part[0] != "{":
|
|
75
|
+
continue
|
|
76
|
+
if part[-1] != "}":
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"Invalid route configuration for URL '{url}': section '{part}'"
|
|
79
|
+
+ " starts with a '{' but does not end with one"
|
|
80
|
+
)
|
|
81
|
+
match = re.match("^{(\\w[\\w\\d_]{0,})\\}$", part)
|
|
82
|
+
if not match:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"Invalid route configuration for URL '{url}', section '{part}': resource identifiers must start with a letter and contain only letters, numbers, and underscores"
|
|
85
|
+
)
|
|
86
|
+
parameter_name = match.group(1)
|
|
87
|
+
if parameter_name in parameter_name_map:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"Invalid route configuration for URL '{url}', a URL path named '{parameter_name}' appeared more than once."
|
|
90
|
+
)
|
|
91
|
+
parameter_name_map[parameter_name] = index
|
|
92
|
+
return parameter_name_map
|