kiba-core 0.5.3.dev55__tar.gz → 0.5.3.dev56__tar.gz
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.
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/CHANGELOG.md +2 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/PKG-INFO +1 -1
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/default_routes.py +3 -2
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/middleware/exception_handling_middleware.py +2 -2
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/store/database.py +3 -0
- kiba_core-0.5.3.dev56/core/util/typing_util.py +5 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/kiba_core.egg-info/PKG-INFO +1 -1
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/kiba_core.egg-info/SOURCES.txt +6 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/pyproject.toml +1 -1
- kiba_core-0.5.3.dev56/tests/api/test_api_response.py +39 -0
- kiba_core-0.5.3.dev56/tests/api/test_exception_handling_middleware.py +109 -0
- kiba_core-0.5.3.dev56/tests/requester/test_requester.py +41 -0
- kiba_core-0.5.3.dev56/tests/test_database.py +121 -0
- kiba_core-0.5.3.dev56/tests/test_exceptions.py +71 -0
- kiba_core-0.5.3.dev56/tests/web3/__init__.py +0 -0
- kiba_core-0.5.3.dev55/core/util/typing_util.py +0 -7
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/.github/pull_request_template.md +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/.github/workflows/deploy.yml +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/.github/workflows/pull-request.yml +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/.github/workflows/release.yml +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/.gitignore +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/Dockerfile +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/README.md +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/__init__.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/__init__.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/api_request.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/api_response.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/authorizer.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/health.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/json_route.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/middleware/__init__.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/middleware/database_connection_middleware.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/middleware/logging_middleware.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/middleware/server_headers_middleware.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/streaming_json_route.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/aws_requester.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/caching/__init__.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/caching/cache.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/caching/dict_cache.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/caching/file_cache.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/exceptions.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/http/__init__.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/http/basic_authentication.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/http/jwt.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/http/rest_method.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/logging.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/notifications/__init__.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/notifications/discord_client.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/notifications/notification_client.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/notifications/slack_client.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/py.typed +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/__init__.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/aqs.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/cosmos.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/message_queue.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/message_queue_processor.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/model.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/sql.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/sqs.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/requester/__init__.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/requester/requester.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/s3_manager.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/service_client.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/store/__init__.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/store/retriever.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/store/saver.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/__init__.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/async_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/chain_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/date_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/dict_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/file_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/hashing_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/http_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/json_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/list_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/string_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/url_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/value_holder.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/web3/__init__.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/web3/eth_client.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/web3/multicall3.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/kiba_core.egg-info/dependency_links.txt +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/kiba_core.egg-info/requires.txt +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/kiba_core.egg-info/top_level.txt +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/makefile +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/setup.cfg +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/__init__.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/api/test_authorizer.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/api/test_json_route.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/api/test_streaming_json_route.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/caching/test_dict_cache.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/caching/test_file_cache.py +0 -0
- {kiba_core-0.5.3.dev55/tests/web3 → kiba_core-0.5.3.dev56/tests/requester}/__init__.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/__init__.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_async_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_chain_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_date_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_dict_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_file_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_json_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_list_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_string_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_url_util.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/web3/test_rest_eth_client.py +0 -0
- {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/uv.lock +0 -0
|
@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
|
+
- [MINOR] Use `json_util`/`orjson` for API and database JSON serialization
|
|
12
|
+
- [MINOR] Use Pydantic `JsonValue` for JSON type aliases
|
|
11
13
|
- [MAJOR] Update web3 to 7.8.0
|
|
12
14
|
- [MINOR] Added `call_function_by_name`, `send_transaction_by_name` and `wait_for_transaction_receipt` to `RestEthClient`
|
|
13
15
|
- [MINOR] Updated `RestEthClient` to use `maxPriorityFeePerGas` and `maxFeePerGas` instead of `gasPrice`
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
from starlette.requests import Request
|
|
2
|
-
from starlette.responses import JSONResponse
|
|
3
2
|
from starlette.responses import Response
|
|
4
3
|
from starlette.routing import BaseRoute
|
|
5
4
|
from starlette.routing import Route
|
|
6
5
|
|
|
6
|
+
from core.api.api_response import KibaJSONResponse
|
|
7
|
+
|
|
7
8
|
|
|
8
9
|
def create_default_routes(name: str, version: str, environment: str) -> list[BaseRoute]:
|
|
9
10
|
async def root(request: Request) -> Response: # noqa: ARG001
|
|
10
|
-
return
|
|
11
|
+
return KibaJSONResponse(content={'server': name, 'version': version, 'environment': environment})
|
|
11
12
|
|
|
12
13
|
return [
|
|
13
14
|
Route('/', methods=['GET'], endpoint=root),
|
{kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/middleware/exception_handling_middleware.py
RENAMED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
2
2
|
from starlette.middleware.base import RequestResponseEndpoint
|
|
3
3
|
from starlette.requests import Request
|
|
4
|
-
from starlette.responses import JSONResponse
|
|
5
4
|
from starlette.responses import Response
|
|
6
5
|
from starlette.types import ASGIApp
|
|
7
6
|
|
|
8
7
|
from core import logging
|
|
8
|
+
from core.api.api_response import KibaJSONResponse
|
|
9
9
|
from core.exceptions import ClientException
|
|
10
10
|
from core.exceptions import KibaException
|
|
11
11
|
from core.exceptions import RedirectException
|
|
@@ -19,7 +19,7 @@ class ExceptionHandlingMiddleware(BaseHTTPMiddleware):
|
|
|
19
19
|
|
|
20
20
|
@staticmethod
|
|
21
21
|
def _convert_exception(exception: KibaException) -> Response:
|
|
22
|
-
response =
|
|
22
|
+
response = KibaJSONResponse(status_code=exception.statusCode, content=exception.to_dict())
|
|
23
23
|
response.headers.update(exception.outgoing_headers())
|
|
24
24
|
return response
|
|
25
25
|
|
|
@@ -14,6 +14,7 @@ from sqlalchemy.sql.selectable import TypedReturnsRows
|
|
|
14
14
|
|
|
15
15
|
from core import logging
|
|
16
16
|
from core.exceptions import InternalServerErrorException
|
|
17
|
+
from core.util import json_util
|
|
17
18
|
|
|
18
19
|
DatabaseConnection = AsyncConnection
|
|
19
20
|
ResultType = TypeVar('ResultType', bound=tuple) # type: ignore[type-arg]
|
|
@@ -43,6 +44,8 @@ class Database:
|
|
|
43
44
|
self.connectionString,
|
|
44
45
|
# echo_pool=True,
|
|
45
46
|
# hide_parameters=False,
|
|
47
|
+
json_serializer=json_util.dumps,
|
|
48
|
+
json_deserializer=json_util.loads,
|
|
46
49
|
pool_size=poolSize,
|
|
47
50
|
pool_recycle=3600,
|
|
48
51
|
pool_pre_ping=True,
|
|
@@ -78,11 +78,17 @@ kiba_core.egg-info/dependency_links.txt
|
|
|
78
78
|
kiba_core.egg-info/requires.txt
|
|
79
79
|
kiba_core.egg-info/top_level.txt
|
|
80
80
|
tests/__init__.py
|
|
81
|
+
tests/test_database.py
|
|
82
|
+
tests/test_exceptions.py
|
|
83
|
+
tests/api/test_api_response.py
|
|
81
84
|
tests/api/test_authorizer.py
|
|
85
|
+
tests/api/test_exception_handling_middleware.py
|
|
82
86
|
tests/api/test_json_route.py
|
|
83
87
|
tests/api/test_streaming_json_route.py
|
|
84
88
|
tests/caching/test_dict_cache.py
|
|
85
89
|
tests/caching/test_file_cache.py
|
|
90
|
+
tests/requester/__init__.py
|
|
91
|
+
tests/requester/test_requester.py
|
|
86
92
|
tests/util/__init__.py
|
|
87
93
|
tests/util/test_async_util.py
|
|
88
94
|
tests/util/test_chain_util.py
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
from starlette.applications import Starlette
|
|
4
|
+
from starlette.requests import Request
|
|
5
|
+
from starlette.routing import Route
|
|
6
|
+
from starlette.testclient import TestClient
|
|
7
|
+
|
|
8
|
+
from core.api.api_response import KibaJSONResponse
|
|
9
|
+
from core.api.default_routes import create_default_routes
|
|
10
|
+
from core.api.middleware.exception_handling_middleware import ExceptionHandlingMiddleware
|
|
11
|
+
from core.exceptions import BadRequestException
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_kiba_json_response_uses_compact_utf8_serialization():
|
|
15
|
+
response = KibaJSONResponse(content={'message': 'Hello 🌍', 'createdAt': datetime.datetime(2025, 1, 2, 3, 4, 5), 'values': [1, 2]})
|
|
16
|
+
|
|
17
|
+
assert response.body == '{"message":"Hello 🌍","createdAt":"2025-01-02T03:04:05","values":[1,2]}'.encode()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_default_route_uses_kiba_json_response():
|
|
21
|
+
app = Starlette(routes=create_default_routes(name='core', version='1.0', environment='test'))
|
|
22
|
+
client = TestClient(app)
|
|
23
|
+
|
|
24
|
+
response = client.get('/')
|
|
25
|
+
|
|
26
|
+
assert response.content == b'{"server":"core","version":"1.0","environment":"test"}'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_exception_handling_middleware_uses_kiba_json_response():
|
|
30
|
+
async def failing_endpoint(_request: Request) -> KibaJSONResponse:
|
|
31
|
+
raise BadRequestException(message='bad input')
|
|
32
|
+
|
|
33
|
+
app = Starlette(routes=[Route('/fail', failing_endpoint)])
|
|
34
|
+
app.add_middleware(ExceptionHandlingMiddleware)
|
|
35
|
+
client = TestClient(app, raise_server_exceptions=False)
|
|
36
|
+
|
|
37
|
+
response = client.get('/fail')
|
|
38
|
+
|
|
39
|
+
assert response.content == b'{"exceptionType":"BadRequestException","message":"bad input","fields":{},"statusCode":400}'
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from starlette.applications import Starlette
|
|
4
|
+
from starlette.routing import Route
|
|
5
|
+
from starlette.testclient import TestClient
|
|
6
|
+
|
|
7
|
+
from core.api.api_request import KibaApiRequest
|
|
8
|
+
from core.api.json_route import json_route
|
|
9
|
+
from core.api.middleware.exception_handling_middleware import ExceptionHandlingMiddleware
|
|
10
|
+
from core.exceptions import BadRequestException
|
|
11
|
+
from core.exceptions import FoundRedirectException
|
|
12
|
+
from core.exceptions import MovedPermanentlyRedirectException
|
|
13
|
+
from core.exceptions import TooManyRequestsException
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SimpleRequest(BaseModel):
|
|
17
|
+
value: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SimpleResponse(BaseModel):
|
|
21
|
+
result: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def rate_limited_client():
|
|
26
|
+
@json_route(requestType=SimpleRequest, responseType=SimpleResponse)
|
|
27
|
+
async def limited_endpoint(request: KibaApiRequest[SimpleRequest]) -> SimpleResponse:
|
|
28
|
+
raise TooManyRequestsException(message='slow down', retryAfterSeconds=30)
|
|
29
|
+
|
|
30
|
+
app = Starlette(routes=[Route('/limited', limited_endpoint, methods=['POST'])])
|
|
31
|
+
app.add_middleware(ExceptionHandlingMiddleware)
|
|
32
|
+
return TestClient(app, raise_server_exceptions=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def rate_limited_no_retry_client():
|
|
37
|
+
@json_route(requestType=SimpleRequest, responseType=SimpleResponse)
|
|
38
|
+
async def limited_endpoint(request: KibaApiRequest[SimpleRequest]) -> SimpleResponse:
|
|
39
|
+
raise TooManyRequestsException(message='slow down')
|
|
40
|
+
|
|
41
|
+
app = Starlette(routes=[Route('/limited', limited_endpoint, methods=['POST'])])
|
|
42
|
+
app.add_middleware(ExceptionHandlingMiddleware)
|
|
43
|
+
return TestClient(app, raise_server_exceptions=False)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def found_redirect_client():
|
|
48
|
+
@json_route(requestType=SimpleRequest, responseType=SimpleResponse)
|
|
49
|
+
async def redirect_endpoint(request: KibaApiRequest[SimpleRequest]) -> SimpleResponse:
|
|
50
|
+
raise FoundRedirectException(location='https://example.com/elsewhere')
|
|
51
|
+
|
|
52
|
+
app = Starlette(routes=[Route('/redirect', redirect_endpoint, methods=['POST'])])
|
|
53
|
+
app.add_middleware(ExceptionHandlingMiddleware)
|
|
54
|
+
return TestClient(app, raise_server_exceptions=False, follow_redirects=False)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.fixture
|
|
58
|
+
def permanent_redirect_client():
|
|
59
|
+
@json_route(requestType=SimpleRequest, responseType=SimpleResponse)
|
|
60
|
+
async def redirect_endpoint(request: KibaApiRequest[SimpleRequest]) -> SimpleResponse:
|
|
61
|
+
raise MovedPermanentlyRedirectException(location='https://example.com/moved')
|
|
62
|
+
|
|
63
|
+
app = Starlette(routes=[Route('/redirect', redirect_endpoint, methods=['POST'])])
|
|
64
|
+
app.add_middleware(ExceptionHandlingMiddleware)
|
|
65
|
+
return TestClient(app, raise_server_exceptions=False, follow_redirects=False)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.fixture
|
|
69
|
+
def plain_error_client():
|
|
70
|
+
@json_route(requestType=SimpleRequest, responseType=SimpleResponse)
|
|
71
|
+
async def failing_endpoint(request: KibaApiRequest[SimpleRequest]) -> SimpleResponse:
|
|
72
|
+
raise BadRequestException(message='bad input')
|
|
73
|
+
|
|
74
|
+
app = Starlette(routes=[Route('/fail', failing_endpoint, methods=['POST'])])
|
|
75
|
+
app.add_middleware(ExceptionHandlingMiddleware)
|
|
76
|
+
return TestClient(app, raise_server_exceptions=False)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_too_many_requests_returns_429_with_retry_after_header(rate_limited_client):
|
|
80
|
+
response = rate_limited_client.post('/limited', json={'value': 'hello'})
|
|
81
|
+
assert response.status_code == 429
|
|
82
|
+
assert response.headers['retry-after'] == '30'
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_too_many_requests_without_retry_after_omits_header(rate_limited_no_retry_client):
|
|
86
|
+
response = rate_limited_no_retry_client.post('/limited', json={'value': 'hello'})
|
|
87
|
+
assert response.status_code == 429
|
|
88
|
+
assert 'retry-after' not in response.headers
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_found_redirect_sets_location_without_cache_header(found_redirect_client):
|
|
92
|
+
response = found_redirect_client.post('/redirect', json={'value': 'hello'})
|
|
93
|
+
assert response.status_code == 302
|
|
94
|
+
assert response.headers['location'] == 'https://example.com/elsewhere'
|
|
95
|
+
assert 'cache-control' not in response.headers
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_permanent_redirect_sets_location_and_cache_header(permanent_redirect_client):
|
|
99
|
+
response = permanent_redirect_client.post('/redirect', json={'value': 'hello'})
|
|
100
|
+
assert response.status_code == 301
|
|
101
|
+
assert response.headers['location'] == 'https://example.com/moved'
|
|
102
|
+
assert response.headers['cache-control'] == f'max-age={60 * 60 * 24 * 365}'
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_plain_exception_has_no_extra_headers(plain_error_client):
|
|
106
|
+
response = plain_error_client.post('/fail', json={'value': 'hello'})
|
|
107
|
+
assert response.status_code == 400
|
|
108
|
+
assert 'retry-after' not in response.headers
|
|
109
|
+
assert 'location' not in response.headers
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from core.exceptions import NotFoundException
|
|
5
|
+
from core.exceptions import TooManyRequestsException
|
|
6
|
+
from core.requester.requester import Requester
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestRequesterExceptionHeaders:
|
|
10
|
+
|
|
11
|
+
@pytest.mark.asyncio
|
|
12
|
+
async def test_429_response_populates_retry_after_from_header(self):
|
|
13
|
+
def handler(request: httpx.Request) -> httpx.Response: # noqa: ARG001
|
|
14
|
+
return httpx.Response(429, headers={'Retry-After': '17'}, text='Too Many Requests')
|
|
15
|
+
|
|
16
|
+
requester = Requester()
|
|
17
|
+
requester.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
18
|
+
with pytest.raises(TooManyRequestsException) as excInfo:
|
|
19
|
+
await requester.get(url='https://example.com/resource')
|
|
20
|
+
assert excInfo.value.retryAfterSeconds == 17
|
|
21
|
+
|
|
22
|
+
@pytest.mark.asyncio
|
|
23
|
+
async def test_429_response_without_retry_after_header(self):
|
|
24
|
+
def handler(request: httpx.Request) -> httpx.Response: # noqa: ARG001
|
|
25
|
+
return httpx.Response(429, text='Too Many Requests')
|
|
26
|
+
|
|
27
|
+
requester = Requester()
|
|
28
|
+
requester.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
29
|
+
with pytest.raises(TooManyRequestsException) as excInfo:
|
|
30
|
+
await requester.get(url='https://example.com/resource')
|
|
31
|
+
assert excInfo.value.retryAfterSeconds is None
|
|
32
|
+
|
|
33
|
+
@pytest.mark.asyncio
|
|
34
|
+
async def test_404_response_still_maps_to_correct_exception_type(self):
|
|
35
|
+
def handler(request: httpx.Request) -> httpx.Response: # noqa: ARG001
|
|
36
|
+
return httpx.Response(404, text='Not Found')
|
|
37
|
+
|
|
38
|
+
requester = Requester()
|
|
39
|
+
requester.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
40
|
+
with pytest.raises(NotFoundException):
|
|
41
|
+
await requester.get(url='https://example.com/resource')
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import sqlalchemy
|
|
5
|
+
from sqlalchemy import JSON
|
|
6
|
+
from sqlalchemy import Column
|
|
7
|
+
from sqlalchemy import MetaData
|
|
8
|
+
from sqlalchemy import Table
|
|
9
|
+
from sqlalchemy import insert
|
|
10
|
+
from sqlalchemy import select
|
|
11
|
+
from sqlalchemy import text
|
|
12
|
+
|
|
13
|
+
from core.exceptions import InternalServerErrorException
|
|
14
|
+
from core.store.database import Database
|
|
15
|
+
from core.util import json_util
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def database(tmp_path):
|
|
20
|
+
return Database(connectionString=Database.create_sqlite_connection_string(str(tmp_path / 'database.sqlite')))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.parametrize(
|
|
24
|
+
('engine', 'username', 'password', 'host', 'port', 'name', 'expected'),
|
|
25
|
+
[
|
|
26
|
+
('postgresql+asyncpg', 'user', 'password', 'localhost', '5432', 'core', 'postgresql+asyncpg://user:password@localhost:5432/core'),
|
|
27
|
+
],
|
|
28
|
+
)
|
|
29
|
+
def test_create_connection_string(engine, username, password, host, port, name, expected):
|
|
30
|
+
assert Database.create_connection_string(engine, username, password, host, port, name) == expected
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_create_psql_connection_string():
|
|
34
|
+
assert Database.create_psql_connection_string('user', 'password', 'localhost', '5432', 'core') == 'postgresql+asyncpg://user:password@localhost:5432/core'
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_create_sqlite_connection_string():
|
|
38
|
+
assert Database.create_sqlite_connection_string('database.sqlite') == 'sqlite+aiosqlite:///database.sqlite'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.asyncio
|
|
42
|
+
async def test_connect_is_idempotent_and_disconnect_clears_engine(database):
|
|
43
|
+
await database.connect(poolSize=1)
|
|
44
|
+
engine = database._engine
|
|
45
|
+
|
|
46
|
+
await database.connect(poolSize=2)
|
|
47
|
+
|
|
48
|
+
assert database._engine is engine
|
|
49
|
+
assert database._engine.sync_engine.dialect._json_serializer is json_util.dumps
|
|
50
|
+
assert database._engine.sync_engine.dialect._json_deserializer is json_util.loads
|
|
51
|
+
|
|
52
|
+
await database.disconnect()
|
|
53
|
+
assert database._engine is None
|
|
54
|
+
await database.disconnect()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.asyncio
|
|
58
|
+
async def test_create_transaction_requires_connection(database):
|
|
59
|
+
with pytest.raises(InternalServerErrorException, match='Engine has not been established'):
|
|
60
|
+
async with database.create_transaction():
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.asyncio
|
|
65
|
+
async def test_execute_requires_connection(database):
|
|
66
|
+
with pytest.raises(InternalServerErrorException, match='Connection has not been established'):
|
|
67
|
+
await database.execute(text('SELECT 1'))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.asyncio
|
|
71
|
+
async def test_json_values_round_trip_through_sqlalchemy(database):
|
|
72
|
+
metadata = MetaData()
|
|
73
|
+
records = Table(
|
|
74
|
+
'records',
|
|
75
|
+
metadata,
|
|
76
|
+
Column('id', sqlalchemy.Integer, primary_key=True),
|
|
77
|
+
Column('payload', JSON, nullable=False),
|
|
78
|
+
)
|
|
79
|
+
payload = {
|
|
80
|
+
'createdAt': datetime.datetime(2025, 1, 2, 3, 4, 5),
|
|
81
|
+
'message': 'Hello 🌍',
|
|
82
|
+
'nested': {'enabled': True, 'values': [None, 1, 2.5]},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await database.connect(poolSize=1)
|
|
86
|
+
async with database.create_transaction() as connection:
|
|
87
|
+
await connection.run_sync(metadata.create_all)
|
|
88
|
+
await database.execute(insert(records).values(payload=payload), connection=connection)
|
|
89
|
+
result = await database.execute(select(records.c.payload), connection=connection)
|
|
90
|
+
|
|
91
|
+
assert result.scalar_one() == {
|
|
92
|
+
'createdAt': '2025-01-02T03:04:05',
|
|
93
|
+
'message': 'Hello 🌍',
|
|
94
|
+
'nested': {'enabled': True, 'values': [None, 1, 2.5]},
|
|
95
|
+
}
|
|
96
|
+
await database.disconnect()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_context_connection_is_used_by_execute(database):
|
|
101
|
+
await database.connect(poolSize=1)
|
|
102
|
+
|
|
103
|
+
async with database.create_context_connection() as connection:
|
|
104
|
+
result = await database.execute(text('SELECT 42 AS answer'))
|
|
105
|
+
assert result.scalar_one() == 42
|
|
106
|
+
assert database._get_context_connection() is connection
|
|
107
|
+
|
|
108
|
+
assert database._get_context_connection() is None
|
|
109
|
+
await database.disconnect()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.asyncio
|
|
113
|
+
async def test_context_connection_cannot_be_nested(database):
|
|
114
|
+
await database.connect(poolSize=1)
|
|
115
|
+
|
|
116
|
+
with pytest.raises(InternalServerErrorException, match='Connection has already been established'):
|
|
117
|
+
async with database.create_context_connection():
|
|
118
|
+
async with database.create_context_connection():
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
await database.disconnect()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import email.utils
|
|
3
|
+
|
|
4
|
+
from core.exceptions import HTTP_EXCEPTIONS_MAP
|
|
5
|
+
from core.exceptions import BadRequestException
|
|
6
|
+
from core.exceptions import RedirectException
|
|
7
|
+
from core.exceptions import TooManyRequestsException
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestExceptionHeaderHooks:
|
|
11
|
+
|
|
12
|
+
def test_default_from_headers_ignores_headers(self):
|
|
13
|
+
exception = BadRequestException.from_headers(message='bad request', statusCode=400, headers={'Retry-After': '99'})
|
|
14
|
+
assert exception.message == 'bad request'
|
|
15
|
+
|
|
16
|
+
def test_default_outgoing_headers_is_empty(self):
|
|
17
|
+
exception = BadRequestException(message='bad request')
|
|
18
|
+
assert exception.outgoing_headers() == {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestTooManyRequestsException:
|
|
22
|
+
|
|
23
|
+
def test_from_headers_parses_delta_seconds(self):
|
|
24
|
+
exception = TooManyRequestsException.from_headers(message='slow down', statusCode=429, headers={'Retry-After': '42'})
|
|
25
|
+
assert exception.retryAfterSeconds == 42
|
|
26
|
+
|
|
27
|
+
def test_from_headers_parses_http_date(self):
|
|
28
|
+
futureDate = email.utils.format_datetime(datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=30))
|
|
29
|
+
exception = TooManyRequestsException.from_headers(message='slow down', statusCode=429, headers={'Retry-After': futureDate})
|
|
30
|
+
assert exception.retryAfterSeconds is not None
|
|
31
|
+
assert 25 <= exception.retryAfterSeconds <= 30
|
|
32
|
+
|
|
33
|
+
def test_from_headers_missing_header_is_none(self):
|
|
34
|
+
exception = TooManyRequestsException.from_headers(message='slow down', statusCode=429, headers={})
|
|
35
|
+
assert exception.retryAfterSeconds is None
|
|
36
|
+
|
|
37
|
+
def test_from_headers_invalid_header_is_none(self):
|
|
38
|
+
exception = TooManyRequestsException.from_headers(message='slow down', statusCode=429, headers={'Retry-After': 'not-a-value'})
|
|
39
|
+
assert exception.retryAfterSeconds is None
|
|
40
|
+
|
|
41
|
+
def test_from_headers_past_http_date_clamps_to_zero(self):
|
|
42
|
+
pastDate = email.utils.format_datetime(datetime.datetime.now(datetime.UTC) - datetime.timedelta(seconds=30))
|
|
43
|
+
exception = TooManyRequestsException.from_headers(message='slow down', statusCode=429, headers={'Retry-After': pastDate})
|
|
44
|
+
assert exception.retryAfterSeconds == 0
|
|
45
|
+
|
|
46
|
+
def test_outgoing_headers_includes_retry_after_when_set(self):
|
|
47
|
+
exception = TooManyRequestsException(message='slow down', retryAfterSeconds=42)
|
|
48
|
+
assert exception.outgoing_headers() == {'Retry-After': '42'}
|
|
49
|
+
|
|
50
|
+
def test_outgoing_headers_empty_when_not_set(self):
|
|
51
|
+
exception = TooManyRequestsException(message='slow down')
|
|
52
|
+
assert exception.outgoing_headers() == {}
|
|
53
|
+
|
|
54
|
+
def test_dispatched_via_http_exceptions_map(self):
|
|
55
|
+
exceptionCls = HTTP_EXCEPTIONS_MAP[429]
|
|
56
|
+
exception = exceptionCls.from_headers(message='slow down', statusCode=429, headers={'Retry-After': '7'})
|
|
57
|
+
assert isinstance(exception, TooManyRequestsException)
|
|
58
|
+
assert exception.retryAfterSeconds == 7
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestRedirectException:
|
|
62
|
+
|
|
63
|
+
def test_outgoing_headers_without_cache_header(self):
|
|
64
|
+
exception = RedirectException(location='https://example.com', statusCode=302, shouldAddCacheHeader=False)
|
|
65
|
+
assert exception.outgoing_headers() == {'Location': 'https://example.com'}
|
|
66
|
+
|
|
67
|
+
def test_outgoing_headers_with_cache_header(self):
|
|
68
|
+
exception = RedirectException(location='https://example.com', statusCode=301, shouldAddCacheHeader=True)
|
|
69
|
+
headers = exception.outgoing_headers()
|
|
70
|
+
assert headers['Location'] == 'https://example.com'
|
|
71
|
+
assert headers['Cache-Control'] == f'max-age={60 * 60 * 24 * 365}'
|
|
File without changes
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
from collections.abc import Mapping
|
|
2
|
-
from collections.abc import Sequence
|
|
3
|
-
|
|
4
|
-
JsonBaseType = str | int | float | bool | None
|
|
5
|
-
type Json = dict[str, 'Json'] | Mapping[str, 'Json'] | list['Json'] | Sequence['Json'] | JsonBaseType
|
|
6
|
-
type JsonObject = dict[str, 'Json']
|
|
7
|
-
type JsonList = list['Json']
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/middleware/server_headers_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|