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,1178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import urllib.parse
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
import clearskies.columns.datetime
|
|
9
|
+
import clearskies.columns.json
|
|
10
|
+
import clearskies.configs
|
|
11
|
+
import clearskies.configurable
|
|
12
|
+
import clearskies.decorators
|
|
13
|
+
import clearskies.model
|
|
14
|
+
import clearskies.query
|
|
15
|
+
from clearskies.autodoc.schema import Integer as AutoDocInteger
|
|
16
|
+
from clearskies.autodoc.schema import Schema as AutoDocSchema
|
|
17
|
+
from clearskies.autodoc.schema import String as AutoDocString
|
|
18
|
+
from clearskies.backends.backend import Backend
|
|
19
|
+
from clearskies.di import InjectableProperties, inject
|
|
20
|
+
from clearskies.functional import routing, string
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import clearskies.column
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ApiBackend(clearskies.configurable.Configurable, Backend, InjectableProperties):
|
|
27
|
+
"""
|
|
28
|
+
Fetch and store data from an API endpoint.
|
|
29
|
+
|
|
30
|
+
The ApiBackend gives developers a way to quickly build SDKs to connect a clearskies applications
|
|
31
|
+
to arbitrary API endpoints. The backend has some built in flexibility to make it easy to connect it to
|
|
32
|
+
**most** APIs, as well as behavioral hooks so that you can override small sections of the logic to accommodate
|
|
33
|
+
APIs that don't work in the expected way. This allows you to interact with APIs using the standard model
|
|
34
|
+
methods, just like every other backend, and also means that you can attach such models to endpoints to
|
|
35
|
+
quickly enable all kinds of pre-defined behaviors.
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
Configuring the API backend is pretty easy:
|
|
40
|
+
|
|
41
|
+
1. Provide the `base_url` to the constructor, or extend it and set it in the `__init__` for the new backend.
|
|
42
|
+
2. Provide a `clearskies.authentication.Authentication` object, assuming it isn't a public API.
|
|
43
|
+
3. Match your model class name to the path of the API (or set `model.destination_name()` appropriately)
|
|
44
|
+
4. Use the resulting model like you would any other model!
|
|
45
|
+
|
|
46
|
+
It's important to understand how the Api Backend will map queries and saves to the API in question. The rules
|
|
47
|
+
are fairly simple:
|
|
48
|
+
|
|
49
|
+
1. The API backend only supports searching with the equals operator (e.g. `models.where("column=value")`).
|
|
50
|
+
2. To specify routing parameters, use the `{parameter_name}` or `:parameter_name` syntax in either the url
|
|
51
|
+
or in the destination name of your model. In order to query the model, you then **must** provide a value
|
|
52
|
+
for any routing parameters, using a matching search condition: (e.g.
|
|
53
|
+
`models.where("routing_parameter_name=value")`)
|
|
54
|
+
3. Any search clauses that don't correspond to routing parameters will be translated into query parameters.
|
|
55
|
+
So, if your destination_name is `https://example.com/:categoy_id/products` and you executed a
|
|
56
|
+
model query: `models.where("category_id=10").where("on_sale=1")` then this would result in fetching
|
|
57
|
+
a URL of `https://example.com/10/products?on_sale=1`
|
|
58
|
+
4. When you specifically search on the id column for the model, the id will be appended to the end
|
|
59
|
+
of the URL rather than as a query parameter. So, with a destination name of `https://example.com/products`,
|
|
60
|
+
querying for `models.find("id=10")` will result in fetching `https://example.com/products/10`.
|
|
61
|
+
5. Delete and Update operations will similarly append the id to the URL, and also set the appropriate
|
|
62
|
+
response method (e.g. `DELETE` or `PATCH` by default).
|
|
63
|
+
6. When processing the response, the backend will attempt to automatically discover the results by looking
|
|
64
|
+
for dictionaries that contain the expected column names (as determined from the model schema and the mapping
|
|
65
|
+
rules).
|
|
66
|
+
7. The backend will check for a response header called `link` and parse this to find pagination information
|
|
67
|
+
so it can iterate through records.
|
|
68
|
+
|
|
69
|
+
NOTE: The API backend doesn't support joins or group_by clauses. This limitation, as well as the fact that it only
|
|
70
|
+
supports seaching with the equals operator, isn't a limitation in the API backend itself, but simply reflects the behavior
|
|
71
|
+
of most API endoints. If you want to support an API that has more flexibility (for instance, perhaps it allows for more search
|
|
72
|
+
operations than just `=`), then you can extend the appropritae methods, discussed below, to map a model query to an API request.
|
|
73
|
+
|
|
74
|
+
Here's an example of how to use the API Backend to integrate with the Github API:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import clearskies
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GithubPublicBackend(clearskies.backends.ApiBackend):
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
# This varies from endpoint to endpoint, so we want to be able to set it for each model
|
|
84
|
+
pagination_parameter_name: str = "since",
|
|
85
|
+
):
|
|
86
|
+
# these are fixed for all gitlab API parameters, so there's no need to make them setable
|
|
87
|
+
# from the constructor
|
|
88
|
+
self.base_url = "https://api.github.com"
|
|
89
|
+
self.limit_parameter_name = "per_page"
|
|
90
|
+
self.pagination_parameter_name = pagination_parameter_name
|
|
91
|
+
self.finalize_and_validate_configuration()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class UserRepo(clearskies.Model):
|
|
95
|
+
# Corresponding API Docs: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-a-user
|
|
96
|
+
id_column_name = "full_name"
|
|
97
|
+
backend = GithubPublicBackend(pagination_parameter_name="page")
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def destination_name(cls) -> str:
|
|
101
|
+
return "users/:login/repos"
|
|
102
|
+
|
|
103
|
+
id = clearskies.columns.Integer()
|
|
104
|
+
full_name = clearskies.columns.String()
|
|
105
|
+
type = clearskies.columns.Select(["all", "owner", "member"])
|
|
106
|
+
url = clearskies.columns.String()
|
|
107
|
+
html_url = clearskies.columns.String()
|
|
108
|
+
created_at = clearskies.columns.Datetime()
|
|
109
|
+
updated_at = clearskies.columns.Datetime()
|
|
110
|
+
|
|
111
|
+
# The API endpoint won't return "login" (e.g. username), so it may not seem like a column, but we need to search by it
|
|
112
|
+
# because it's a URL parameter for this API endpoint. Clearskies uses strict validation and won't let us search by
|
|
113
|
+
# a column that doesn't exist in the model: therefore, we have to add the login column.
|
|
114
|
+
login = clearskies.columns.String(is_searchable=True, is_readable=False)
|
|
115
|
+
|
|
116
|
+
# The API endpoint let's us sort by `created`/`updated`. Note that the names of the columns (based on the data returned
|
|
117
|
+
# by the API endpoint) are `created_at`/`updated_at`. As above, clearskies strictly validates data, so we need columns
|
|
118
|
+
# named created/updated so that we can sort by them. We can set some flags to (hopefully) avoid confusion
|
|
119
|
+
updated = clearskies.columns.Datetime(
|
|
120
|
+
is_searchable=False, is_readable=False, is_writeable=False
|
|
121
|
+
)
|
|
122
|
+
created = clearskies.columns.Datetime(
|
|
123
|
+
is_searchable=False, is_readable=False, is_writeable=False
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class User(clearskies.Model):
|
|
128
|
+
# Corresponding API docs: https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#list-users
|
|
129
|
+
|
|
130
|
+
# github has two columns that are both effecitvely id columns: id and login.
|
|
131
|
+
# We use the login column for id_column_name because that is the column that gets
|
|
132
|
+
# used in the API to fetch an individual record
|
|
133
|
+
id_column_name = "login"
|
|
134
|
+
backend = GithubPublicBackend()
|
|
135
|
+
|
|
136
|
+
id = clearskies.columns.Integer()
|
|
137
|
+
login = clearskies.columns.String()
|
|
138
|
+
gravatar_id = clearskies.columns.String()
|
|
139
|
+
avatar_url = clearskies.columns.String()
|
|
140
|
+
html_url = clearskies.columns.String()
|
|
141
|
+
repos_url = clearskies.columns.String()
|
|
142
|
+
|
|
143
|
+
# We can hook up relationships between models just like we would if we were using an SQL-like
|
|
144
|
+
# database. The whole point of the backend system is that the model queries work regardless of
|
|
145
|
+
# backend, so clearskies can issue API calls to fetch related records just like it would be able
|
|
146
|
+
# to fetch children from a related database table.
|
|
147
|
+
repos = clearskies.columns.HasMany(
|
|
148
|
+
UserRepo,
|
|
149
|
+
foreign_column_name="login",
|
|
150
|
+
readable_child_columns=["id", "full_name", "html_url"],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def fetch_user(users: User, user_repos: UserRepo):
|
|
155
|
+
# If we execute this models query:
|
|
156
|
+
some_repos = (
|
|
157
|
+
user_repos.where("login=cmancone")
|
|
158
|
+
.sort_by("created", "desc")
|
|
159
|
+
.where("type=owner")
|
|
160
|
+
.pagination(page=2)
|
|
161
|
+
.limit(5)
|
|
162
|
+
)
|
|
163
|
+
# the API backend will fetch this url:
|
|
164
|
+
# https://api.github.com/users/cmancone/repos?type=owner&sort=created&direction=desc&per_page=5&page=2
|
|
165
|
+
# and we can use the results like always
|
|
166
|
+
repo_names = [repo.full_name for repo in some_repos]
|
|
167
|
+
|
|
168
|
+
# For the below case, the backend will fetch this url:
|
|
169
|
+
# https://api.github.com/users/cmancone
|
|
170
|
+
# in addition, the readable column names on the callable endpoint includes "repos", which references our has_many
|
|
171
|
+
# column. This means that when converting the user model to JSON, it will also grab a page of repositories for that user.
|
|
172
|
+
# To do that, it will fetch this URL:
|
|
173
|
+
# https://api.github.com/users/cmancone/repos
|
|
174
|
+
return users.find("login=cmancone")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
wsgi = clearskies.contexts.WsgiRef(
|
|
178
|
+
clearskies.endpoints.Callable(
|
|
179
|
+
fetch_user,
|
|
180
|
+
model_class=User,
|
|
181
|
+
readable_column_names=["id", "login", "html_url", "repos"],
|
|
182
|
+
),
|
|
183
|
+
classes=[User, UserRepo],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
wsgi()
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The following example demonstrates how models using this backend can be used in other clearskies endpoints, just like any
|
|
191
|
+
other model. Note that the following example is re-using the above models and backend, I have just omitted them for the sake
|
|
192
|
+
of brevity:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
wsgi = clearskies.contexts.WsgiRef(
|
|
196
|
+
clearskies.endpoints.List(
|
|
197
|
+
model_class=User,
|
|
198
|
+
readable_column_names=["id", "login", "html_url"],
|
|
199
|
+
sortable_column_names=["id"],
|
|
200
|
+
default_sort_column_name=None,
|
|
201
|
+
default_limit=10,
|
|
202
|
+
),
|
|
203
|
+
classes=[User],
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
wsgi()
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
And if you invoke it:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
$ curl 'http://localhost:8080' | jq
|
|
214
|
+
{
|
|
215
|
+
"status": "success",
|
|
216
|
+
"error": "",
|
|
217
|
+
"data": [
|
|
218
|
+
{
|
|
219
|
+
"id": 1,
|
|
220
|
+
"login": "mojombo",
|
|
221
|
+
"html_url": "https://github.com/mojombo"
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
"id": 2,
|
|
225
|
+
"login": "defunkt",
|
|
226
|
+
"html_url": "https://github.com/defunkt"
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
"id": 3,
|
|
230
|
+
"login": "pjhyett",
|
|
231
|
+
"html_url": "https://github.com/pjhyett"
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
"id": 4,
|
|
235
|
+
"login": "wycats",
|
|
236
|
+
"html_url": "https://github.com/wycats"
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"id": 5,
|
|
240
|
+
"login": "ezmobius",
|
|
241
|
+
"html_url": "https://github.com/ezmobius"
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
"id": 6,
|
|
245
|
+
"login": "ivey",
|
|
246
|
+
"html_url": "https://github.com/ivey"
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
"id": 7,
|
|
250
|
+
"login": "evanphx",
|
|
251
|
+
"html_url": "https://github.com/evanphx"
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
"id": 17,
|
|
255
|
+
"login": "vanpelt",
|
|
256
|
+
"html_url": "https://github.com/vanpelt"
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
"id": 18,
|
|
260
|
+
"login": "wayneeseguin",
|
|
261
|
+
"html_url": "https://github.com/wayneeseguin"
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
"id": 19,
|
|
265
|
+
"login": "brynary",
|
|
266
|
+
"html_url": "https://github.com/brynary"
|
|
267
|
+
}
|
|
268
|
+
],
|
|
269
|
+
"pagination": {
|
|
270
|
+
"number_results": null,
|
|
271
|
+
"limit": 10,
|
|
272
|
+
"next_page": {
|
|
273
|
+
"since": "19"
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
"input_errors": {}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
In essence, we now have an endpoint that lists results but, instead of pulling its data from a database, it
|
|
281
|
+
makes API calls. It also tracks pagination as expected, so you can use the data in `pagination.next_page` to
|
|
282
|
+
fetch the next set of results, just as you would if this were backed by a database, e.g.:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
$ curl http://localhost:8080?since=19
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## Mapping from Queries to API calls
|
|
289
|
+
|
|
290
|
+
The process of mapping a model query into an API request involves a few different methods which can be
|
|
291
|
+
overwritten to fully control the process. This is necessary in cases where an API behaves differently
|
|
292
|
+
than expected by the API backend. This table outlines the method involved and how they are used:
|
|
293
|
+
|
|
294
|
+
| Method | Description |
|
|
295
|
+
|----------------------------------|-------------------------------------------------------------------------------------------------------|
|
|
296
|
+
| records_url | Return the absolute URL to fetch, as well as any columns that were used to fill in routing parameters |
|
|
297
|
+
| records_method | Reurn the HTTP request method to use for the API call |
|
|
298
|
+
| conditions_to_request_parameters | Translate the query conditions into URL fragments, query parameters, or JSON body parameters |
|
|
299
|
+
| pagination_to_request_parameters | Translate the pagination data into URL fragments, query parameters, or JSON body parameters |
|
|
300
|
+
| sorts_to_request_parameters | Translate the sort directive(s) into URL fragments, query parameters, or JSON body parameters |
|
|
301
|
+
| map_records_response | Take the response from the API and return a list of dictionaries with the resulting records |
|
|
302
|
+
|
|
303
|
+
In short, the details of the query are stored in a clearskies.query.Query object which is passed around to these
|
|
304
|
+
various methods. They use that information to adjust the URL, add query parameters, or add parameters into the
|
|
305
|
+
JSON body. The API Backend will then execute an API call with those final details, and use the map_record_response
|
|
306
|
+
method to pull the returned records out of the response from the API endpoint.
|
|
307
|
+
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
can_count = False
|
|
311
|
+
|
|
312
|
+
"""
|
|
313
|
+
The Base URL for the requests - will be prepended to the destination_name() from the model.
|
|
314
|
+
|
|
315
|
+
Note: this is treated as a 'folder' path: if set, it becomes the URL prefix and is followed with a '/'
|
|
316
|
+
"""
|
|
317
|
+
base_url = clearskies.configs.String(default="")
|
|
318
|
+
|
|
319
|
+
"""
|
|
320
|
+
A suffix to append to the end of the URL.
|
|
321
|
+
|
|
322
|
+
Note: this is treated as a 'folder' path: if set, it becomes the URL suffix and is prefixed with a '/'
|
|
323
|
+
"""
|
|
324
|
+
url_suffix = clearskies.configs.String(default="")
|
|
325
|
+
|
|
326
|
+
"""
|
|
327
|
+
An instance of clearskies.authentication.Authentication that handles authentication to the API.
|
|
328
|
+
|
|
329
|
+
The following example is a modification of the Github Backends used above that shows how to setup authentication.
|
|
330
|
+
Github, like many APIs, uses an API key attached to the request via the authorization header. The SecretBearer
|
|
331
|
+
authentication class in clearskies is designed for this common use case, and pulls the secret key out of either
|
|
332
|
+
an environment variable or the secret manager (I use the former in this case, because it's hard to have a
|
|
333
|
+
self-contained example with a secret manager). Of course, any authentication method can be attached to your
|
|
334
|
+
API backend - SecretBearer authentication is used here simply because it's a common approach.
|
|
335
|
+
|
|
336
|
+
Note that, when used in conjunction with a secret manager, the API Backend and the SecretBearer class will work
|
|
337
|
+
together to check for a new secret in the event of an authentication failure from the API endpoint (specifically,
|
|
338
|
+
a 401 error). This allows you to automate credential rotation: create a new API key, put it in the secret manager,
|
|
339
|
+
and then revoke the old API key. The next time an API call is made, the SecretBearer will provide the old key from
|
|
340
|
+
it's cache and the request will fail. The API backend will detect this and try the request again, but this time
|
|
341
|
+
will tell the SecretBearer class to refresh it's cache with a fresh copy of the key from the secrets manager.
|
|
342
|
+
Therefore, as long as you put the new key in your secret manager **before** disabling the old key, this second
|
|
343
|
+
request will succeed and the service will continue to operate successfully with only a slight delay in response time
|
|
344
|
+
caused by refreshing the cache.
|
|
345
|
+
|
|
346
|
+
```python
|
|
347
|
+
import clearskies
|
|
348
|
+
|
|
349
|
+
class GithubBackend(clearskies.backends.ApiBackend):
|
|
350
|
+
def __init__(
|
|
351
|
+
self,
|
|
352
|
+
pagination_parameter_name: str = "page",
|
|
353
|
+
authentication: clearskies.authentication.Authentication | None = None,
|
|
354
|
+
):
|
|
355
|
+
self.base_url = "https://api.github.com"
|
|
356
|
+
self.limit_parameter_name = "per_page"
|
|
357
|
+
self.pagination_parameter_name = pagination_parameter_name
|
|
358
|
+
self.authentication = clearskies.authentication.SecretBearer(
|
|
359
|
+
environment_key="GITHUB_API_KEY",
|
|
360
|
+
header_prefix="Bearer ", # Because github expects a header of 'Authorization: Bearer API_KEY'
|
|
361
|
+
)
|
|
362
|
+
self.finalize_and_validate_configuration()
|
|
363
|
+
|
|
364
|
+
class Repo(clearskies.Model):
|
|
365
|
+
id_column_name = "login"
|
|
366
|
+
backend = GithubBackend()
|
|
367
|
+
|
|
368
|
+
@classmethod
|
|
369
|
+
def destination_name(cls):
|
|
370
|
+
return "/user/repos"
|
|
371
|
+
|
|
372
|
+
id = clearskies.columns.Integer()
|
|
373
|
+
name = clearskies.columns.String()
|
|
374
|
+
full_name = clearskies.columns.String()
|
|
375
|
+
html_url = clearskies.columns.String()
|
|
376
|
+
visibility = clearskies.columns.Select(["all", "public", "private"])
|
|
377
|
+
|
|
378
|
+
wsgi = clearskies.contexts.WsgiRef(
|
|
379
|
+
clearskies.endpoints.List(
|
|
380
|
+
model_class=Repo,
|
|
381
|
+
readable_column_names=["id", "name", "full_name", "html_url"],
|
|
382
|
+
sortable_column_names=["full_name"],
|
|
383
|
+
default_sort_column_name="full_name",
|
|
384
|
+
default_limit=10,
|
|
385
|
+
where=["visibility=private"],
|
|
386
|
+
),
|
|
387
|
+
classes=[Repo],
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if __name__ == "__main__":
|
|
391
|
+
wsgi()
|
|
392
|
+
|
|
393
|
+
```
|
|
394
|
+
"""
|
|
395
|
+
authentication = clearskies.configs.Authentication(default=None)
|
|
396
|
+
|
|
397
|
+
"""
|
|
398
|
+
A dictionary of headers to attach to all outgoing API requests
|
|
399
|
+
"""
|
|
400
|
+
headers = clearskies.configs.StringDict(default={})
|
|
401
|
+
|
|
402
|
+
"""
|
|
403
|
+
The casing used in the model (snake_case, camelCase, TitleCase)
|
|
404
|
+
|
|
405
|
+
This is used in conjunction with api_casing to tell the processing layer when you and the API are using
|
|
406
|
+
different casing standards. The API backend will then automatically covnert the casing style of the API
|
|
407
|
+
to match your model. This can be helpful when you have a standard naming convention in your own code which
|
|
408
|
+
some external API doesn't follow, that way you can at least standardize things in your code. In the following
|
|
409
|
+
example, these parameters are used to convert from the snake_casing native to the Github API into the
|
|
410
|
+
TitleCasing used in the model class:
|
|
411
|
+
|
|
412
|
+
```python
|
|
413
|
+
import clearskies
|
|
414
|
+
|
|
415
|
+
class User(clearskies.Model):
|
|
416
|
+
id_column_name = "login"
|
|
417
|
+
backend = clearskies.backends.ApiBackend(
|
|
418
|
+
base_url="https://api.github.com",
|
|
419
|
+
limit_parameter_name="per_page",
|
|
420
|
+
pagination_parameter_name="since",
|
|
421
|
+
model_casing="TitleCase",
|
|
422
|
+
api_casing="snake_case",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
Id = clearskies.columns.Integer()
|
|
426
|
+
Login = clearskies.columns.String()
|
|
427
|
+
GravatarId = clearskies.columns.String()
|
|
428
|
+
AvatarUrl = clearskies.columns.String()
|
|
429
|
+
HtmlUrl = clearskies.columns.String()
|
|
430
|
+
ReposUrl = clearskies.columns.String()
|
|
431
|
+
|
|
432
|
+
wsgi = clearskies.contexts.WsgiRef(
|
|
433
|
+
clearskies.endpoints.List(
|
|
434
|
+
model_class=User,
|
|
435
|
+
readable_column_names=["Login", "AvatarUrl", "HtmlUrl", "ReposUrl"],
|
|
436
|
+
sortable_column_names=["Id"],
|
|
437
|
+
default_sort_column_name=None,
|
|
438
|
+
default_limit=2,
|
|
439
|
+
internal_casing="TitleCase",
|
|
440
|
+
external_casing="TitleCase",
|
|
441
|
+
),
|
|
442
|
+
classes=[User],
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
if __name__ == "__main__":
|
|
446
|
+
wsgi()
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
and when executed:
|
|
450
|
+
|
|
451
|
+
```bash
|
|
452
|
+
$ curl http://localhost:8080 | jq
|
|
453
|
+
{
|
|
454
|
+
"Status": "Success",
|
|
455
|
+
"Error": "",
|
|
456
|
+
"Data": [
|
|
457
|
+
{
|
|
458
|
+
"Login": "mojombo",
|
|
459
|
+
"AvatarUrl": "https://avatars.githubusercontent.com/u/1?v=4",
|
|
460
|
+
"HtmlUrl": "https://github.com/mojombo",
|
|
461
|
+
"ReposUrl": "https://api.github.com/users/mojombo/repos"
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
"Login": "defunkt",
|
|
465
|
+
"AvatarUrl": "https://avatars.githubusercontent.com/u/2?v=4",
|
|
466
|
+
"HtmlUrl": "https://github.com/defunkt",
|
|
467
|
+
"ReposUrl": "https://api.github.com/users/defunkt/repos"
|
|
468
|
+
}
|
|
469
|
+
],
|
|
470
|
+
"Pagination": {
|
|
471
|
+
"NumberResults": null,
|
|
472
|
+
"Limit": 2,
|
|
473
|
+
"NextPage": {
|
|
474
|
+
"Since": "2"
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
"InputErrors": {}
|
|
478
|
+
}
|
|
479
|
+
```
|
|
480
|
+
"""
|
|
481
|
+
model_casing = clearskies.configs.Select(["snake_case", "camelCase", "TitleCase"], default="snake_case")
|
|
482
|
+
|
|
483
|
+
"""
|
|
484
|
+
The casing used by the API response (snake_case, camelCase, TitleCase)
|
|
485
|
+
|
|
486
|
+
See model_casing for details and usage.
|
|
487
|
+
"""
|
|
488
|
+
api_casing = clearskies.configs.Select(["snake_case", "camelCase", "TitleCase"], default="snake_case")
|
|
489
|
+
|
|
490
|
+
"""
|
|
491
|
+
A mapping from the data keys returned by the API to the data keys expected in the model
|
|
492
|
+
|
|
493
|
+
This comes into play when you want your model columns to use different names than what is returned by the
|
|
494
|
+
API itself. Provide a dictionary where the key is the name of a piece of data from the API, and the value
|
|
495
|
+
is the name of the column in the model. The API Backend will use this to match the API data to your model.
|
|
496
|
+
In the example below, `html_url` from the API has been mapped to `profile_url` in the model:
|
|
497
|
+
|
|
498
|
+
```python
|
|
499
|
+
import clearskies
|
|
500
|
+
|
|
501
|
+
class User(clearskies.Model):
|
|
502
|
+
id_column_name = "login"
|
|
503
|
+
backend = clearskies.backends.ApiBackend(
|
|
504
|
+
base_url="https://api.github.com",
|
|
505
|
+
limit_parameter_name="per_page",
|
|
506
|
+
pagination_parameter_name="since",
|
|
507
|
+
api_to_model_map={"html_url": "profile_url"},
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
id = clearskies.columns.Integer()
|
|
511
|
+
login = clearskies.columns.String()
|
|
512
|
+
profile_url = clearskies.columns.String()
|
|
513
|
+
|
|
514
|
+
wsgi = clearskies.contexts.WsgiRef(
|
|
515
|
+
clearskies.endpoints.List(
|
|
516
|
+
model_class=User,
|
|
517
|
+
readable_column_names=["login", "profile_url"],
|
|
518
|
+
sortable_column_names=["id"],
|
|
519
|
+
default_sort_column_name=None,
|
|
520
|
+
default_limit=2,
|
|
521
|
+
),
|
|
522
|
+
classes=[User],
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
if __name__ == "__main__":
|
|
526
|
+
wsgi()
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
And if you invoke it:
|
|
530
|
+
|
|
531
|
+
```bash
|
|
532
|
+
$ curl http://localhost:8080 | jq
|
|
533
|
+
{
|
|
534
|
+
"status": "success",
|
|
535
|
+
"error": "",
|
|
536
|
+
"data": [
|
|
537
|
+
{
|
|
538
|
+
"login": "mojombo",
|
|
539
|
+
"profile_url": "https://github.com/mojombo"
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
"login": "defunkt",
|
|
543
|
+
"profile_url": "https://github.com/defunkt"
|
|
544
|
+
}
|
|
545
|
+
],
|
|
546
|
+
"pagination": {
|
|
547
|
+
"number_results": null,
|
|
548
|
+
"limit": 2,
|
|
549
|
+
"next_page": {
|
|
550
|
+
"since": "2"
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
"input_errors": {}
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
"""
|
|
557
|
+
api_to_model_map = clearskies.configs.StringDict(default={})
|
|
558
|
+
|
|
559
|
+
"""
|
|
560
|
+
The name of the pagination parameter
|
|
561
|
+
"""
|
|
562
|
+
pagination_parameter_name = clearskies.configs.String(default="start")
|
|
563
|
+
|
|
564
|
+
"""
|
|
565
|
+
The expected 'type' of the pagination parameter: must be either 'int' or 'str'
|
|
566
|
+
|
|
567
|
+
Note: this is set as a literal string, not as a type.
|
|
568
|
+
"""
|
|
569
|
+
pagination_parameter_type = clearskies.configs.Select(["int", "str"], default="str")
|
|
570
|
+
|
|
571
|
+
"""
|
|
572
|
+
The name of the parameter that sets the number of records per page (if empty, setting the page size will not be allowed)
|
|
573
|
+
"""
|
|
574
|
+
limit_parameter_name = clearskies.configs.String(default="limit")
|
|
575
|
+
|
|
576
|
+
"""
|
|
577
|
+
The requests instance.
|
|
578
|
+
"""
|
|
579
|
+
requests = inject.Requests()
|
|
580
|
+
|
|
581
|
+
"""
|
|
582
|
+
The dependency injection container (so we can pass it along to the Authentication object)
|
|
583
|
+
"""
|
|
584
|
+
di = inject.Di()
|
|
585
|
+
|
|
586
|
+
_auth_injected = False
|
|
587
|
+
_response_to_model_map: dict[str, str] = None # type: ignore
|
|
588
|
+
|
|
589
|
+
@clearskies.decorators.parameters_to_properties
|
|
590
|
+
def __init__(
|
|
591
|
+
self,
|
|
592
|
+
base_url: str,
|
|
593
|
+
authentication: clearskies.authentication.Authentication | None = None,
|
|
594
|
+
model_casing: str = "snake_case",
|
|
595
|
+
api_casing: str = "snake_case",
|
|
596
|
+
api_to_model_map: dict[str, str] = {},
|
|
597
|
+
pagination_parameter_name: str = "start",
|
|
598
|
+
pagination_parameter_type: str = "str",
|
|
599
|
+
limit_parameter_name: str = "limit",
|
|
600
|
+
):
|
|
601
|
+
self.finalize_and_validate_configuration()
|
|
602
|
+
|
|
603
|
+
def finalize_url(self, url: str, available_routing_data: dict[str, str], operation: str) -> tuple[str, list[str]]:
|
|
604
|
+
"""
|
|
605
|
+
Given a URL, this will append the base URL, fill in any routing data, and also return any used routing parameters.
|
|
606
|
+
|
|
607
|
+
For example, consider a base URL of `/my/api/{record_id}/:other_id` and then this is called as so:
|
|
608
|
+
|
|
609
|
+
```python
|
|
610
|
+
(url, used_routing_parameters) = api_backend.finalize_url(
|
|
611
|
+
"entries",
|
|
612
|
+
{
|
|
613
|
+
"record_id": "1-2-3-4",
|
|
614
|
+
"other_id": "a-s-d-f",
|
|
615
|
+
"more_things": "qwerty",
|
|
616
|
+
},
|
|
617
|
+
)
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
The returned url would be `/my/api/1-2-3-4/a-s-d-f/entries`, and used_routing_parameters would be ["record_id", "other_id"].
|
|
621
|
+
The latter is returned so you can understand what parameters were absorbed into the URL. Often, when some piece of data
|
|
622
|
+
becomes a routing parameter, it needs to be ignored in the rest of the request. `used_routing_parameters` helps with that.
|
|
623
|
+
"""
|
|
624
|
+
base_url = self.base_url.strip("/") + "/" if self.base_url.strip("/") else ""
|
|
625
|
+
url_suffix = "/" + self.url_suffix.strip("/") if self.url_suffix.strip("/") else ""
|
|
626
|
+
url = base_url + url + url_suffix
|
|
627
|
+
routing_parameters = routing.extract_url_parameter_name_map(url)
|
|
628
|
+
if not routing_parameters:
|
|
629
|
+
return (url, [])
|
|
630
|
+
|
|
631
|
+
parts = url.split("/")
|
|
632
|
+
used_routing_parameters = []
|
|
633
|
+
for parameter_name, index in routing_parameters.items():
|
|
634
|
+
if parameter_name not in available_routing_data:
|
|
635
|
+
a = "an" if operation == "update" else "a"
|
|
636
|
+
raise ValueError(
|
|
637
|
+
f"""Failed to generate URL while building {a} {operation} request! Url {url} hsa a routing parameter named
|
|
638
|
+
{parameter_name} that I couldn't fill in from the request details. When fetching records, this should be
|
|
639
|
+
provided by adding an equals condition to the model, e.g. `model.where("{parameter_name}=some_value")`.
|
|
640
|
+
When creating/updating a record, this should be provided in the save data, e.g.:
|
|
641
|
+
`model.save({{"{parameter_name}": "some_value"}})`
|
|
642
|
+
"""
|
|
643
|
+
)
|
|
644
|
+
if available_routing_data[parameter_name].__class__ not in [str, int]:
|
|
645
|
+
parameter_type = available_routing_data[parameter_name].__class__.__name__
|
|
646
|
+
raise ValueError(
|
|
647
|
+
f"I was filling in a routing parameter named {parameter_name} but the value I was given has a type of {parameter_type}. Routing parameters can only be strings or integers."
|
|
648
|
+
)
|
|
649
|
+
parts[index] = available_routing_data[parameter_name]
|
|
650
|
+
used_routing_parameters.append(parameter_name)
|
|
651
|
+
return ("/".join(parts), used_routing_parameters)
|
|
652
|
+
|
|
653
|
+
def finalize_url_from_data(self, url: str, data: dict[str, Any], operation: str) -> tuple[str, list[str]]:
|
|
654
|
+
"""
|
|
655
|
+
Create the final URL using a data dictionary to fill in any URL parameters.
|
|
656
|
+
|
|
657
|
+
See finalize_url for more details about the return value
|
|
658
|
+
"""
|
|
659
|
+
return self.finalize_url(url, data, operation)
|
|
660
|
+
|
|
661
|
+
def finalize_url_from_query(self, query: clearskies.query.Query, operation: str) -> tuple[str, list[str]]:
|
|
662
|
+
"""
|
|
663
|
+
Create the URL using a query to fill in any URL parameters.
|
|
664
|
+
|
|
665
|
+
See finalize_url for more details about the return value
|
|
666
|
+
"""
|
|
667
|
+
available_routing_data = {}
|
|
668
|
+
for condition in query.conditions:
|
|
669
|
+
if condition.operator != "=":
|
|
670
|
+
continue
|
|
671
|
+
available_routing_data[condition.column_name] = condition.values[0]
|
|
672
|
+
return self.finalize_url(query.model_class.destination_name(), available_routing_data, operation)
|
|
673
|
+
|
|
674
|
+
def create_url(self, data: dict[str, Any], model: clearskies.model.Model) -> tuple[str, list[str]]:
|
|
675
|
+
"""
|
|
676
|
+
Calculate the URL to use for a create requst. Also, return the list of ay data parameters used to construct the URL.
|
|
677
|
+
|
|
678
|
+
See finalize_url for more details on the return value.
|
|
679
|
+
"""
|
|
680
|
+
return self.finalize_url_from_data(model.destination_name(), data, "create")
|
|
681
|
+
|
|
682
|
+
def create_method(self, data: dict[str, Any], model: clearskies.model.Model) -> str:
|
|
683
|
+
"""Return the request method to use with a create request."""
|
|
684
|
+
return "POST"
|
|
685
|
+
|
|
686
|
+
def records_url(self, query: clearskies.query.Query) -> tuple[str, list[str]]:
|
|
687
|
+
"""
|
|
688
|
+
Calculate the URL to use for a records request. Also, return the list of any query parameters used to construct the URL.
|
|
689
|
+
|
|
690
|
+
See finalize_url for more details on the return value.
|
|
691
|
+
"""
|
|
692
|
+
return self.finalize_url_from_query(query, "records")
|
|
693
|
+
|
|
694
|
+
def records_method(self, query: clearskies.query.Query) -> str:
|
|
695
|
+
"""Return the request method to use when fetching records from the API."""
|
|
696
|
+
return "GET"
|
|
697
|
+
|
|
698
|
+
def count_url(self, query: clearskies.query.Query) -> tuple[str, list[str]]:
|
|
699
|
+
"""
|
|
700
|
+
Calculate the URL to use for a request to get a record count.. Also, return the list of any query parameters used to construct the URL.
|
|
701
|
+
|
|
702
|
+
See finalize_url for more details on the return value.
|
|
703
|
+
"""
|
|
704
|
+
return self.records_url(query)
|
|
705
|
+
|
|
706
|
+
def count_method(self, query: clearskies.query.Query) -> str:
|
|
707
|
+
"""Return the request method to use when making a request for a record count."""
|
|
708
|
+
return self.records_method(query)
|
|
709
|
+
|
|
710
|
+
def delete_url(self, id: int | str, model: clearskies.model.Model) -> tuple[str, list[str]]:
|
|
711
|
+
"""
|
|
712
|
+
Calculate the URL to use for a delete request. Also, return the list of any query parameters used to construct the URL.
|
|
713
|
+
|
|
714
|
+
See finalize_url for more details on the return value.
|
|
715
|
+
"""
|
|
716
|
+
model_base_url = model.destination_name().strip("/") + "/" if model.destination_name() else ""
|
|
717
|
+
return self.finalize_url_from_data(f"{model_base_url}{id}", model.get_raw_data(), "delete")
|
|
718
|
+
|
|
719
|
+
def delete_method(self, id: int | str, model: clearskies.model.Model) -> str:
|
|
720
|
+
"""Return the request method to use when deleting records via the API."""
|
|
721
|
+
return "DELETE"
|
|
722
|
+
|
|
723
|
+
def update_url(self, id: int | str, data: dict[str, Any], model: clearskies.model.Model) -> tuple[str, list[str]]:
|
|
724
|
+
"""
|
|
725
|
+
Calculate the URL to use for an update request. Also, return the list of any query parameters used to construct the URL.
|
|
726
|
+
|
|
727
|
+
See finalize_url for more details on the return value.
|
|
728
|
+
"""
|
|
729
|
+
model_base_url = model.destination_name().strip("/") + "/" if model.destination_name() else ""
|
|
730
|
+
return self.finalize_url_from_data(f"{model_base_url}{id}", {**model.get_raw_data(), **data}, "update")
|
|
731
|
+
|
|
732
|
+
def update_method(self, id: int | str, data: dict[str, Any], model: clearskies.model.Model) -> str:
|
|
733
|
+
"""Return the request method to use for an update request."""
|
|
734
|
+
return "PATCH"
|
|
735
|
+
|
|
736
|
+
def update(self, id: int | str, data: dict[str, Any], model: clearskies.model.Model) -> dict[str, Any]:
|
|
737
|
+
"""Update a record."""
|
|
738
|
+
data = {**data}
|
|
739
|
+
(url, used_routing_parameters) = self.update_url(id, data, model)
|
|
740
|
+
request_method = self.update_method(id, data, model)
|
|
741
|
+
for parameter in used_routing_parameters:
|
|
742
|
+
del data[parameter]
|
|
743
|
+
|
|
744
|
+
response = self.execute_request(url, request_method, json=data)
|
|
745
|
+
json_response = response.json() if response.content else {}
|
|
746
|
+
new_record = {**model.get_raw_data(), **data}
|
|
747
|
+
if response.content:
|
|
748
|
+
new_record = {**new_record, **self.map_update_response(response.json(), model)}
|
|
749
|
+
return new_record
|
|
750
|
+
|
|
751
|
+
def map_update_response(self, response_data: dict[str, Any], model: clearskies.model.Model) -> dict[str, Any]:
|
|
752
|
+
"""
|
|
753
|
+
Take the response from the API endpoint for an update request and figure out where the data lives/return it to build a new model.
|
|
754
|
+
|
|
755
|
+
See self.map_record_response for goals/motiviation
|
|
756
|
+
"""
|
|
757
|
+
return self.map_record_response(response_data, model.get_columns(), "update")
|
|
758
|
+
|
|
759
|
+
def create(self, data: dict[str, Any], model: clearskies.model.Model) -> dict[str, Any]:
|
|
760
|
+
"""Create a record."""
|
|
761
|
+
data = {**data}
|
|
762
|
+
(url, used_routing_parameters) = self.create_url(data, model)
|
|
763
|
+
request_method = self.create_method(data, model)
|
|
764
|
+
for parameter in used_routing_parameters:
|
|
765
|
+
del data[parameter]
|
|
766
|
+
|
|
767
|
+
response = self.execute_request(url, request_method, json=data, headers=self.headers)
|
|
768
|
+
json_response = response.json() if response.content else {}
|
|
769
|
+
if response.content:
|
|
770
|
+
return self.map_create_response(response.json(), model)
|
|
771
|
+
return {}
|
|
772
|
+
|
|
773
|
+
def map_create_response(self, response_data: dict[str, Any], model: clearskies.model.Model) -> dict[str, Any]:
|
|
774
|
+
return self.map_record_response(response_data, model.get_columns(), "create")
|
|
775
|
+
|
|
776
|
+
def delete(self, id: int | str, model: clearskies.model.Model) -> bool:
|
|
777
|
+
(url, used_routing_parameters) = self.delete_url(id, model)
|
|
778
|
+
request_method = self.delete_method(id, model)
|
|
779
|
+
|
|
780
|
+
response = self.execute_request(url, request_method)
|
|
781
|
+
return True
|
|
782
|
+
|
|
783
|
+
def records(
|
|
784
|
+
self, query: clearskies.query.Query, next_page_data: dict[str, str | int] | None = None
|
|
785
|
+
) -> list[dict[str, Any]]:
|
|
786
|
+
self.check_query(query)
|
|
787
|
+
(url, method, body, headers) = self.build_records_request(query)
|
|
788
|
+
response = self.execute_request(url, method, json=body, headers=headers)
|
|
789
|
+
records = self.map_records_response(response.json(), query)
|
|
790
|
+
if isinstance(next_page_data, dict):
|
|
791
|
+
self.set_next_page_data_from_response(next_page_data, query, response)
|
|
792
|
+
return records
|
|
793
|
+
|
|
794
|
+
def build_records_request(self, query: clearskies.query.Query) -> tuple[str, str, dict[str, Any], dict[str, str]]:
|
|
795
|
+
(url, used_routing_parameters) = self.records_url(query)
|
|
796
|
+
|
|
797
|
+
(condition_route_id, condition_url_parameters, condition_body_parameters) = (
|
|
798
|
+
self.conditions_to_request_parameters(query, used_routing_parameters)
|
|
799
|
+
)
|
|
800
|
+
(pagination_url_parameters, pagination_body_parameters) = self.pagination_to_request_parameters(query)
|
|
801
|
+
(sort_url_parameters, sort_body_parameters) = self.sorts_to_request_parameters(query)
|
|
802
|
+
|
|
803
|
+
url_parameters = {
|
|
804
|
+
**condition_url_parameters,
|
|
805
|
+
**pagination_url_parameters,
|
|
806
|
+
**sort_url_parameters,
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
body_parameters = {
|
|
810
|
+
**condition_body_parameters,
|
|
811
|
+
**pagination_body_parameters,
|
|
812
|
+
**sort_body_parameters,
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if condition_route_id:
|
|
816
|
+
url = url.rstrip("/") + "/" + condition_route_id
|
|
817
|
+
if url_parameters:
|
|
818
|
+
url = url + "?" + urllib.parse.urlencode(url_parameters)
|
|
819
|
+
|
|
820
|
+
return (
|
|
821
|
+
url,
|
|
822
|
+
self.records_method(query),
|
|
823
|
+
body_parameters,
|
|
824
|
+
{},
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
def conditions_to_request_parameters(
|
|
828
|
+
self, query: clearskies.query.Query, used_routing_parameters: list[str]
|
|
829
|
+
) -> tuple[str, dict[str, str], dict[str, Any]]:
|
|
830
|
+
route_id = ""
|
|
831
|
+
|
|
832
|
+
url_parameters = {}
|
|
833
|
+
for condition in query.conditions:
|
|
834
|
+
if condition.column_name in used_routing_parameters:
|
|
835
|
+
continue
|
|
836
|
+
if condition.operator != "=":
|
|
837
|
+
raise ValueError(
|
|
838
|
+
f"I'm not very smart and only know how to search with the equals operator, but I received a condition of {condition.parsed}. If you need to support this, you'll have to extend the ApiBackend and overwrite the build_records_request method."
|
|
839
|
+
)
|
|
840
|
+
if condition.column_name == query.model_class.id_column_name:
|
|
841
|
+
route_id = condition.values[0]
|
|
842
|
+
continue
|
|
843
|
+
url_parameters[condition.column_name] = condition.values[0]
|
|
844
|
+
|
|
845
|
+
return (route_id, url_parameters, {})
|
|
846
|
+
|
|
847
|
+
def pagination_to_request_parameters(self, query: clearskies.query.Query) -> tuple[dict[str, str], dict[str, Any]]:
|
|
848
|
+
url_parameters = {}
|
|
849
|
+
if query.limit:
|
|
850
|
+
if not self.limit_parameter_name:
|
|
851
|
+
raise ValueError(
|
|
852
|
+
"The records query attempted to change the limit (the number of results per page) but the backend does not support it. If it actually does support this, then set an appropriate value for backend.limit_parameter_name"
|
|
853
|
+
)
|
|
854
|
+
url_parameters[self.limit_parameter_name] = str(query.limit)
|
|
855
|
+
|
|
856
|
+
if query.pagination.get(self.pagination_parameter_name):
|
|
857
|
+
url_parameters[self.pagination_parameter_name] = str(query.pagination.get(self.pagination_parameter_name))
|
|
858
|
+
|
|
859
|
+
return (url_parameters, {})
|
|
860
|
+
|
|
861
|
+
def sorts_to_request_parameters(self, query: clearskies.query.Query) -> tuple[dict[str, str], dict[str, Any]]:
|
|
862
|
+
if not query.sorts:
|
|
863
|
+
return ({}, {})
|
|
864
|
+
|
|
865
|
+
if len(query.sorts) > 1:
|
|
866
|
+
raise ValueError(
|
|
867
|
+
"I received a query with two sort directives, but I can only handle one. Sorry! If you need o support two sort directions, you'll have to extend the ApiBackend and overwrite the build_records_request method."
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
return (
|
|
871
|
+
{"sort": query.sorts[0].column_name, "direction": query.sorts[0].direction.lower()},
|
|
872
|
+
{},
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
def map_records_response(
|
|
876
|
+
self, response_data: Any, query: clearskies.query.Query, query_data: dict[str, Any] | None = None
|
|
877
|
+
) -> list[dict[str, Any]]:
|
|
878
|
+
"""Take the response from an API endpoint that returns a list of records and find the actual list of records."""
|
|
879
|
+
columns = query.model_class.get_columns()
|
|
880
|
+
# turn all of our conditions into record data and inject these into the results. We do this to keep around
|
|
881
|
+
# any query parameters. This is especially important for any URL parameters, wihch aren't always returned in
|
|
882
|
+
# the data, but which we are likely to need again if we go to update/delete the record.
|
|
883
|
+
if query_data is None:
|
|
884
|
+
query_data = {}
|
|
885
|
+
for condition in query.conditions:
|
|
886
|
+
if condition.operator != "=":
|
|
887
|
+
continue
|
|
888
|
+
query_data[condition.column_name] = condition.values[0]
|
|
889
|
+
|
|
890
|
+
# if our response is actually a list, then presumably the problem is solved. If the response is a list
|
|
891
|
+
# and the individual items aren't model results though... well, then I'm very confused
|
|
892
|
+
if isinstance(response_data, list):
|
|
893
|
+
if not response_data:
|
|
894
|
+
return []
|
|
895
|
+
if not self.check_dict_and_map_to_model(response_data[0], columns, query_data):
|
|
896
|
+
raise ValueError(
|
|
897
|
+
f"The response from a records request returned a list, but the records in the list didn't look anything like the model class. Please check your model class and mapping settings in the API Backend. If those are correct, then you'll have to override the map_records_response method, because the API you are interacting with is returning data in an unexpected way that I can't automatically figure out."
|
|
898
|
+
)
|
|
899
|
+
return [self.check_dict_and_map_to_model(record, columns, query_data) for record in response_data] # type: ignore
|
|
900
|
+
|
|
901
|
+
if not isinstance(response_data, dict):
|
|
902
|
+
raise ValueError(
|
|
903
|
+
f"The response from a records request returned a variable of type {response_data.__class__.__name__}, which is just confusing. To do automatic introspection, I need a list or a dictionary. I'm afraid you'll have to extend the API backend and override the map_record_response method to deal with this."
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
for key, value in response_data.items():
|
|
907
|
+
if not isinstance(value, list):
|
|
908
|
+
continue
|
|
909
|
+
return self.map_records_response(value, query, query_data)
|
|
910
|
+
|
|
911
|
+
# a records request may only return a single record, so before we fail, let's check for that
|
|
912
|
+
record = self.check_dict_and_map_to_model(response_data, columns, query_data)
|
|
913
|
+
if record is not None:
|
|
914
|
+
return [record]
|
|
915
|
+
|
|
916
|
+
raise ValueError(
|
|
917
|
+
"The response from a records request returned a dictionary, but none of the items in the dictionary was a list, so I don't know where to find the records. I only ever check one level deep in dictionaries. I'm afraid you'll have to extend the API backend and override the map_records_response method to deal with this."
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
def map_record_response(
|
|
921
|
+
self, response_data: dict[str, Any], columns: dict[str, clearskies.column.Column], operation: str
|
|
922
|
+
) -> dict[str, Any]:
|
|
923
|
+
"""
|
|
924
|
+
Take the response from an API endpoint that returns a single record (typically update and create requests) and return the data for a new model.
|
|
925
|
+
|
|
926
|
+
The goal of this method is to try to use the model schema to automatically understand the response from the
|
|
927
|
+
the API endpoint. The goal is for the backend to work out-of-the-box with most APIs. In general, it works
|
|
928
|
+
by iterating over the response, looking for a dictionary with keys that match the expected model columns.
|
|
929
|
+
|
|
930
|
+
Occassionally the automatic introspection may not be able to make sense of the response from an API
|
|
931
|
+
endoint. If this happens, you have to make a new API backend, override the map_record_response method
|
|
932
|
+
to manage the mapping yourself, and then attach this new backend to your models.
|
|
933
|
+
"""
|
|
934
|
+
an = "a" if operation == "create" else "an"
|
|
935
|
+
if not isinstance(response_data, dict):
|
|
936
|
+
raise ValueError(
|
|
937
|
+
f"The response from {an} {operation} request returned a variable of type {response_data.__class__.__name__}, which is just confusing. To do automatic introspection, I need a dictionary. I'm afraid you'll have to build your own API backend and override the map_record_response method to deal with this."
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
response = self.check_dict_and_map_to_model(response_data, columns)
|
|
941
|
+
if response is None:
|
|
942
|
+
raise ValueError(
|
|
943
|
+
f"I was not able to automatically interpret the response from {an} {operation} request. This could be a sign of a response that is structured in a very unusual way, or may be a sign that the casing settings and/or columns on your model to properly reflect the API response. For the former, you will hvae to build your own API backend and override the map_record_response to deal with this."
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
return response
|
|
947
|
+
|
|
948
|
+
def check_dict_and_map_to_model(
|
|
949
|
+
self,
|
|
950
|
+
response_data: dict[str, Any],
|
|
951
|
+
columns: dict[str, clearskies.column.Column],
|
|
952
|
+
query_data: dict[str, Any] = {},
|
|
953
|
+
) -> dict[str, Any] | None:
|
|
954
|
+
"""
|
|
955
|
+
Check a dictionary in the response to decide if it contains the data for a record.
|
|
956
|
+
|
|
957
|
+
If not, it will search the keys for something that looks like a record.
|
|
958
|
+
"""
|
|
959
|
+
# first let's get a coherent map of expected-key-names in the response to model names
|
|
960
|
+
response_to_model_map = self.build_response_to_model_map(columns)
|
|
961
|
+
|
|
962
|
+
# and now we can see if that appears to be what we have
|
|
963
|
+
response_keys = set(response_data.keys())
|
|
964
|
+
map_keys = set(response_to_model_map.keys())
|
|
965
|
+
matching = response_keys.intersection(map_keys)
|
|
966
|
+
|
|
967
|
+
# if nothing matches then clearly this isn't what we're looking for: repeat on all the children
|
|
968
|
+
if not matching:
|
|
969
|
+
for key, value in response_data.items():
|
|
970
|
+
if not isinstance(value, dict):
|
|
971
|
+
continue
|
|
972
|
+
mapped = self.check_dict_and_map_to_model(value, columns)
|
|
973
|
+
if mapped:
|
|
974
|
+
return {**query_data, **mapped}
|
|
975
|
+
|
|
976
|
+
# no match anywhere :(
|
|
977
|
+
return None
|
|
978
|
+
|
|
979
|
+
# we may need to be smarter about whether or not we think we found a match, but for now let's
|
|
980
|
+
# ignore that possibility. If any columns match between the keys in our response dictionary and
|
|
981
|
+
# the keys that we are expecting to find data in, then just assume that we have found a record.
|
|
982
|
+
mapped = {response_to_model_map[key]: response_data[key] for key in matching}
|
|
983
|
+
|
|
984
|
+
# finally, move over anything not mentioned in the map
|
|
985
|
+
for key in response_keys.difference(map_keys):
|
|
986
|
+
mapped[string.swap_casing(key, self.api_casing, self.model_casing)] = response_data[key]
|
|
987
|
+
|
|
988
|
+
return {**query_data, **mapped}
|
|
989
|
+
|
|
990
|
+
def build_response_to_model_map(self, columns: dict[str, clearskies.column.Column]) -> dict[str, str]:
|
|
991
|
+
if self._response_to_model_map is not None:
|
|
992
|
+
return self._response_to_model_map
|
|
993
|
+
|
|
994
|
+
self._response_to_model_map = {}
|
|
995
|
+
for column_name in columns:
|
|
996
|
+
self._response_to_model_map[string.swap_casing(column_name, self.model_casing, self.api_casing)] = (
|
|
997
|
+
column_name
|
|
998
|
+
)
|
|
999
|
+
self._response_to_model_map = {**self._response_to_model_map, **self.api_to_model_map}
|
|
1000
|
+
|
|
1001
|
+
return self._response_to_model_map
|
|
1002
|
+
|
|
1003
|
+
def set_next_page_data_from_response(
|
|
1004
|
+
self,
|
|
1005
|
+
next_page_data: dict[str, Any],
|
|
1006
|
+
query: clearskies.query.Query,
|
|
1007
|
+
response: requests.Response, # type: ignore
|
|
1008
|
+
) -> None:
|
|
1009
|
+
"""
|
|
1010
|
+
Update the next_page_data dictionary with the appropriate data needed to fetch the next page of records.
|
|
1011
|
+
|
|
1012
|
+
This method has a very important job, which is to inform clearskies about how to make another API call to fetch the next
|
|
1013
|
+
page of records. The way this happens is by updating the `next_page_data` dictionary in place with whatever pagination
|
|
1014
|
+
information is necessary. Note that this relies on next_page_data being passed by reference, hence the need to update
|
|
1015
|
+
it in place. That means that you can do this:
|
|
1016
|
+
|
|
1017
|
+
```python
|
|
1018
|
+
next_page_data["some_key"] = "some_value"
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
but if you do this:
|
|
1022
|
+
|
|
1023
|
+
```python
|
|
1024
|
+
next_page_data = {"some_key": "some_value"}
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
Then things simply won't work.
|
|
1028
|
+
"""
|
|
1029
|
+
# Different APIs generally have completely different ways of communicating pagination data, but one somewhat common
|
|
1030
|
+
# approach is to use a link header, so let's support that in the base class.
|
|
1031
|
+
if "link" not in response.headers:
|
|
1032
|
+
return
|
|
1033
|
+
next_link = [rel for rel in response.headers["link"].split(",") if 'rel="next"' in rel]
|
|
1034
|
+
if not next_link:
|
|
1035
|
+
return
|
|
1036
|
+
parsed_next_link = urllib.parse.urlparse(next_link[0].split(";")[0].strip(" <>"))
|
|
1037
|
+
query_parameters = urllib.parse.parse_qs(parsed_next_link.query)
|
|
1038
|
+
if self.pagination_parameter_name not in query_parameters:
|
|
1039
|
+
raise ValueError(
|
|
1040
|
+
f"Configuration error with {self.__class__.__name__}! I am configured to expect a pagination key of '{self.pagination_parameter_name}. However, when I was parsing the next link from a response to get the next pagination details, I could not find the designated pagination key. This likely means that backend.pagination_parameter_name is set to the wrong value. The link in question was "
|
|
1041
|
+
+ parsed_next_link.geturl()
|
|
1042
|
+
)
|
|
1043
|
+
next_page_data[self.pagination_parameter_name] = query_parameters[self.pagination_parameter_name][0]
|
|
1044
|
+
|
|
1045
|
+
def count(self, query: clearskies.query.Query) -> int:
|
|
1046
|
+
raise NotImplementedError(
|
|
1047
|
+
f"The {self.__class__.__name__} backend does not support count operations, so you can't use the `len` or `bool` function for any models using it."
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
def execute_request(
|
|
1051
|
+
self,
|
|
1052
|
+
url: str,
|
|
1053
|
+
method: str,
|
|
1054
|
+
json: dict[str, Any] | None = None,
|
|
1055
|
+
headers: dict[str, str] | None = None,
|
|
1056
|
+
is_retry=False,
|
|
1057
|
+
) -> requests.models.Response: # type: ignore
|
|
1058
|
+
"""
|
|
1059
|
+
Execute the actual API request and returns the response object.
|
|
1060
|
+
|
|
1061
|
+
We don't directly call the requests library to support retries in the event of failed authentication. The goal
|
|
1062
|
+
is to support short-lived credentials, and our authentication classes denote if they support this feature. If
|
|
1063
|
+
they do, and the requests fails, then we'll ask the authentication method to refresh its credentials and we
|
|
1064
|
+
will retry the request.
|
|
1065
|
+
"""
|
|
1066
|
+
if json is None:
|
|
1067
|
+
json = {}
|
|
1068
|
+
if headers is None:
|
|
1069
|
+
headers = {}
|
|
1070
|
+
|
|
1071
|
+
if self.authentication:
|
|
1072
|
+
if not self._auth_injected:
|
|
1073
|
+
self._auth_injected = True
|
|
1074
|
+
if hasattr(self.authentication, "injectable_properties"):
|
|
1075
|
+
self.authentication.injectable_properties(self.di)
|
|
1076
|
+
if is_retry:
|
|
1077
|
+
self.authentication.clear_credential_cache()
|
|
1078
|
+
# the requests library seems to build a slightly different request if you specify the json parameter,
|
|
1079
|
+
# even if it is null, and this causes trouble for some picky servers
|
|
1080
|
+
if not json:
|
|
1081
|
+
response = self.requests.request(
|
|
1082
|
+
method,
|
|
1083
|
+
url,
|
|
1084
|
+
headers=headers,
|
|
1085
|
+
auth=self.authentication if self.authentication else None,
|
|
1086
|
+
)
|
|
1087
|
+
else:
|
|
1088
|
+
response = self.requests.request(
|
|
1089
|
+
method,
|
|
1090
|
+
url,
|
|
1091
|
+
headers=headers,
|
|
1092
|
+
json=json,
|
|
1093
|
+
auth=self.authentication if self.authentication else None,
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
if not response.ok:
|
|
1097
|
+
if not is_retry and response.status_code == 401:
|
|
1098
|
+
return self.execute_request(url, method, json=json, headers=headers, is_retry=True)
|
|
1099
|
+
if not response.ok:
|
|
1100
|
+
raise ValueError(
|
|
1101
|
+
f"Failed request. Status code: {response.status_code}, message: "
|
|
1102
|
+
+ response.content.decode("utf-8")
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
return response
|
|
1106
|
+
|
|
1107
|
+
def check_query(self, query: clearskies.query.Query) -> None:
|
|
1108
|
+
for key in ["joins", "group_by", "selects"]:
|
|
1109
|
+
if getattr(query, key):
|
|
1110
|
+
raise ValueError(f"{self.__class__.__name__} does not support queries with {key}")
|
|
1111
|
+
|
|
1112
|
+
for condition in query.conditions:
|
|
1113
|
+
if condition.operator != "=":
|
|
1114
|
+
raise ValueError(
|
|
1115
|
+
f"{self.__class__.__name__} only supports searching with the '=' operator, but I found a search with the {condition.operator} operator"
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
def validate_pagination_data(self, data: dict[str, Any], case_mapping: Callable) -> str:
|
|
1119
|
+
extra_keys = set(data.keys()) - set(self.allowed_pagination_keys())
|
|
1120
|
+
if len(extra_keys):
|
|
1121
|
+
key_name = case_mapping(self.pagination_parameter_name)
|
|
1122
|
+
return "Invalid pagination key(s): '" + "','".join(extra_keys) + f"'. Only '{key_name}' is allowed"
|
|
1123
|
+
if self.pagination_parameter_name not in data:
|
|
1124
|
+
key_name = case_mapping(self.pagination_parameter_name)
|
|
1125
|
+
return f"You must specify '{key_name}' when setting pagination"
|
|
1126
|
+
value = data[self.pagination_parameter_name]
|
|
1127
|
+
try:
|
|
1128
|
+
if self.pagination_parameter_type == "int":
|
|
1129
|
+
converted = int(value)
|
|
1130
|
+
except:
|
|
1131
|
+
key_name = case_mapping(self.pagination_parameter_name)
|
|
1132
|
+
return f"Invalid pagination data: '{key_name}' must be a number"
|
|
1133
|
+
return ""
|
|
1134
|
+
|
|
1135
|
+
def allowed_pagination_keys(self) -> list[str]:
|
|
1136
|
+
return [self.pagination_parameter_name]
|
|
1137
|
+
|
|
1138
|
+
def documentation_pagination_next_page_response(self, case_mapping: Callable) -> list[Any]:
|
|
1139
|
+
if self.pagination_parameter_type == "int":
|
|
1140
|
+
return [AutoDocInteger(case_mapping(self.pagination_parameter_name), example=0)]
|
|
1141
|
+
else:
|
|
1142
|
+
return [AutoDocString(case_mapping(self.pagination_parameter_name), example="")]
|
|
1143
|
+
|
|
1144
|
+
def documentation_pagination_next_page_example(self, case_mapping: Callable) -> dict[str, Any]:
|
|
1145
|
+
return {case_mapping(self.pagination_parameter_name): 0 if self.pagination_parameter_type == "int" else ""}
|
|
1146
|
+
|
|
1147
|
+
def documentation_pagination_parameters(self, case_mapping: Callable) -> list[tuple[AutoDocSchema, str]]:
|
|
1148
|
+
return [
|
|
1149
|
+
(
|
|
1150
|
+
AutoDocInteger(
|
|
1151
|
+
case_mapping(self.pagination_parameter_name),
|
|
1152
|
+
example=0 if self.pagination_parameter_type == "int" else "",
|
|
1153
|
+
),
|
|
1154
|
+
"The next record",
|
|
1155
|
+
)
|
|
1156
|
+
]
|
|
1157
|
+
|
|
1158
|
+
def column_from_backend(self, column: clearskies.column.Column, value: Any) -> Any:
|
|
1159
|
+
"""We have a couple columns we want to override transformations for."""
|
|
1160
|
+
# most importantly, there's no need to transform a JSON column in either direction
|
|
1161
|
+
if isinstance(column, clearskies.columns.json.Json):
|
|
1162
|
+
return value
|
|
1163
|
+
return super().column_from_backend(column, value)
|
|
1164
|
+
|
|
1165
|
+
def column_to_backend(self, column: clearskies.column.Column, backend_data: dict[str, Any]) -> dict[str, Any]:
|
|
1166
|
+
"""We have a couple columns we want to override transformations for."""
|
|
1167
|
+
# most importantly, there's no need to transform a JSON column in either direction
|
|
1168
|
+
if isinstance(column, clearskies.columns.json.Json):
|
|
1169
|
+
return backend_data
|
|
1170
|
+
# also, APIs tend to have a different format for dates than SQL
|
|
1171
|
+
if isinstance(column, clearskies.columns.datetime.Datetime) and column.name in backend_data:
|
|
1172
|
+
as_date = (
|
|
1173
|
+
backend_data[column.name].isoformat()
|
|
1174
|
+
if type(backend_data[column.name]) != str
|
|
1175
|
+
else backend_data[column.name]
|
|
1176
|
+
)
|
|
1177
|
+
return {**backend_data, **{column.name: as_date}}
|
|
1178
|
+
return column.to_backend(backend_data)
|