bazis-async-request 2.2.0__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.
- bazis/contrib/async_request/__init__.py +19 -0
- bazis/contrib/async_request/_version.py +34 -0
- bazis/contrib/async_request/apps.py +25 -0
- bazis/contrib/async_request/middleware.py +104 -0
- bazis/contrib/async_request/schemas.py +36 -0
- bazis/contrib/async_request/tasks.py +133 -0
- bazis/contrib/async_request/utils.py +67 -0
- bazis_async_request-2.2.0.dist-info/METADATA +706 -0
- bazis_async_request-2.2.0.dist-info/RECORD +11 -0
- bazis_async_request-2.2.0.dist-info/WHEEL +5 -0
- bazis_async_request-2.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
17
|
+
__version__ = version('bazis-async-request')
|
|
18
|
+
except PackageNotFoundError:
|
|
19
|
+
__version__ = 'dev'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '2.2.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 2, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from django.utils.translation import gettext_lazy as _
|
|
16
|
+
|
|
17
|
+
from bazis.core.utils.apps import BaseConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AsyncRequestConfig(BaseConfig):
|
|
21
|
+
"""Django AppConfig for the asynchronous background processing package (async_request)."""
|
|
22
|
+
|
|
23
|
+
name = "bazis.contrib.async_request"
|
|
24
|
+
verbose_name = _("AsyncRequest")
|
|
25
|
+
default = True
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
from django.conf import settings
|
|
20
|
+
|
|
21
|
+
from fastapi import Request
|
|
22
|
+
|
|
23
|
+
from starlette.datastructures import Headers
|
|
24
|
+
from starlette.responses import JSONResponse
|
|
25
|
+
|
|
26
|
+
from bazis.contrib.async_background.producer import enqueue_task_async
|
|
27
|
+
from bazis.contrib.async_background.routes import get_async_background_response
|
|
28
|
+
from bazis.contrib.async_background.utils import ChannelNameError, resolve_channel_name_async
|
|
29
|
+
|
|
30
|
+
from .utils import build_request_payload
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AsyncRequestMiddleware:
|
|
37
|
+
def __init__(self, app):
|
|
38
|
+
self.app = app
|
|
39
|
+
self._no_bg_prefixes: tuple[str, ...] | None = None
|
|
40
|
+
|
|
41
|
+
def _build_no_bg_prefixes(self, app) -> tuple[str, ...]:
|
|
42
|
+
prefixes: list[str] = []
|
|
43
|
+
path = app.url_path_for(get_async_background_response.__name__, task_id="__dummy__")
|
|
44
|
+
if path:
|
|
45
|
+
prefix = str(path).replace("/__dummy__/", "/")
|
|
46
|
+
while "//" in prefix:
|
|
47
|
+
prefix = prefix.replace("//", "/")
|
|
48
|
+
prefixes.append(prefix)
|
|
49
|
+
return tuple(prefixes)
|
|
50
|
+
|
|
51
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
52
|
+
if scope.get("type") != "http":
|
|
53
|
+
await self.app(scope, receive, send)
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if self._no_bg_prefixes is None:
|
|
57
|
+
scope_app = scope.get("app")
|
|
58
|
+
print(scope_app.user_middleware)
|
|
59
|
+
self._no_bg_prefixes = self._build_no_bg_prefixes(scope_app or self.app)
|
|
60
|
+
|
|
61
|
+
path = scope.get("path", "")
|
|
62
|
+
if self._no_bg_prefixes and any(path.startswith(prefix) for prefix in self._no_bg_prefixes):
|
|
63
|
+
await self.app(scope, receive, send)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
headers = Headers(scope=scope)
|
|
67
|
+
if (
|
|
68
|
+
headers.get("X-Async-Background-Internal", "").lower() == "true"
|
|
69
|
+
or "X-Async-Background" not in headers
|
|
70
|
+
):
|
|
71
|
+
await self.app(scope, receive, send)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
if not settings.KAFKA_ENABLED:
|
|
75
|
+
logger.warning(
|
|
76
|
+
"Incorrect Kafka settings, it is impossible to execute the request in the background."
|
|
77
|
+
)
|
|
78
|
+
await self.app(scope, receive, send)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
request = Request(scope, receive)
|
|
82
|
+
await request.body()
|
|
83
|
+
try:
|
|
84
|
+
channel_name = await resolve_channel_name_async(request)
|
|
85
|
+
except ChannelNameError as err:
|
|
86
|
+
response = JSONResponse(status_code=401, content={'detail': str(err)})
|
|
87
|
+
await response(scope, receive, send)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
payload = build_request_payload(request)
|
|
91
|
+
message = await enqueue_task_async(
|
|
92
|
+
topic_name=settings.KAFKA_TOPIC_ASYNC_REQUEST,
|
|
93
|
+
channel_name=channel_name,
|
|
94
|
+
payload=payload,
|
|
95
|
+
partition_marker=(
|
|
96
|
+
payload.body.get("data", {}).get("id") if isinstance(payload.body, dict) else None
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
response = JSONResponse(
|
|
101
|
+
status_code=202,
|
|
102
|
+
content={"data": None, "meta": {"async_request_id": message.task_id}},
|
|
103
|
+
)
|
|
104
|
+
await response(scope, receive, send)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AsyncRequestPayload(BaseModel):
|
|
20
|
+
"""Payload of a background HTTP request serialized for Kafka."""
|
|
21
|
+
|
|
22
|
+
path: str = Field(..., description="Request path")
|
|
23
|
+
query_string: str = Field(..., description="Query string")
|
|
24
|
+
headers: list[tuple[str | bytes, str | bytes]] = Field(
|
|
25
|
+
..., description="List of HTTP headers"
|
|
26
|
+
) # List of tuples (header_name, header_value)
|
|
27
|
+
request_client: str | tuple | None = Field(..., description="Client address of the request")
|
|
28
|
+
method: str = Field(..., description="HTTP method")
|
|
29
|
+
type: str = Field(..., description="Request type: http or websocket")
|
|
30
|
+
http_version: str = Field(..., description="HTTP version")
|
|
31
|
+
scheme: str = Field(..., description="Request scheme")
|
|
32
|
+
body: dict | list[dict] = Field(default_factory=dict, description="Request body")
|
|
33
|
+
|
|
34
|
+
class Config:
|
|
35
|
+
json_encoders = {bytes: lambda v: v.decode("utf-8")}
|
|
36
|
+
use_enum_values = True
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
from django.conf import settings
|
|
20
|
+
|
|
21
|
+
from bazis.contrib.async_background.broker import get_broker_for_consumer
|
|
22
|
+
from bazis.contrib.async_background.schemas import KafkaTask, TaskStatus
|
|
23
|
+
from bazis.contrib.async_background.utils import set_and_publish_status_async
|
|
24
|
+
from bazis.contrib.async_request.schemas import AsyncRequestPayload
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_subscriber_kwargs: dict[str, object] = {
|
|
31
|
+
"auto_offset_reset": settings.KAFKA_AUTO_OFFSET_RESET,
|
|
32
|
+
"auto_commit": settings.KAFKA_ENABLE_AUTO_COMMIT,
|
|
33
|
+
"auto_commit_interval_ms": settings.KAFKA_AUTO_COMMIT_INTERVAL_MS,
|
|
34
|
+
}
|
|
35
|
+
if settings.KAFKA_GROUP_ID:
|
|
36
|
+
_subscriber_kwargs["group_id"] = settings.KAFKA_GROUP_ID
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@get_broker_for_consumer().subscriber(settings.KAFKA_TOPIC_ASYNC_REQUEST, **_subscriber_kwargs)
|
|
40
|
+
async def consumer_async_requests(task: KafkaTask[AsyncRequestPayload]):
|
|
41
|
+
"""Executes a background HTTP request from Kafka."""
|
|
42
|
+
|
|
43
|
+
await set_and_publish_status_async(
|
|
44
|
+
task_id=task.task_id,
|
|
45
|
+
channel_name=task.channel_name,
|
|
46
|
+
status=TaskStatus.PROCESSING,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
response = await execute_internal_request(task)
|
|
51
|
+
except Exception as err:
|
|
52
|
+
logger.exception("Failed to process task_id=%s", task.task_id)
|
|
53
|
+
await set_and_publish_status_async(
|
|
54
|
+
task_id=task.task_id,
|
|
55
|
+
channel_name=task.channel_name,
|
|
56
|
+
status=TaskStatus.FAILED,
|
|
57
|
+
response={"error": str(err)},
|
|
58
|
+
)
|
|
59
|
+
raise
|
|
60
|
+
|
|
61
|
+
await set_and_publish_status_async(
|
|
62
|
+
task_id=task.task_id,
|
|
63
|
+
channel_name=task.channel_name,
|
|
64
|
+
status=TaskStatus.COMPLETED,
|
|
65
|
+
response=response,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
logger.info(
|
|
69
|
+
"Processed task_id=%s with status=%s.",
|
|
70
|
+
task.task_id,
|
|
71
|
+
response.get("status"),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def execute_internal_request(task: KafkaTask[AsyncRequestPayload]) -> dict:
|
|
76
|
+
"""Executes an internal HTTP request and returns the result."""
|
|
77
|
+
request = task.payload
|
|
78
|
+
|
|
79
|
+
url = urlparse(request.path)
|
|
80
|
+
|
|
81
|
+
headers = []
|
|
82
|
+
for key, value in request.headers:
|
|
83
|
+
key_bytes = key if isinstance(key, bytes) else str(key).encode("utf-8")
|
|
84
|
+
value_bytes = value if isinstance(value, bytes) else str(value).encode("utf-8")
|
|
85
|
+
headers.append((key_bytes, value_bytes))
|
|
86
|
+
|
|
87
|
+
scope = {
|
|
88
|
+
"type": request.type,
|
|
89
|
+
"http_version": request.http_version,
|
|
90
|
+
"method": request.method,
|
|
91
|
+
"scheme": request.scheme,
|
|
92
|
+
"path": url.path,
|
|
93
|
+
"raw_path": url.path.encode("utf-8"),
|
|
94
|
+
"query_string": request.query_string.encode(),
|
|
95
|
+
"headers": headers,
|
|
96
|
+
"client": request.request_client,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
result = {
|
|
100
|
+
"task_id": task.task_id,
|
|
101
|
+
"endpoint": request.path,
|
|
102
|
+
"status": None,
|
|
103
|
+
"headers": [],
|
|
104
|
+
"response": None,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async def receive():
|
|
108
|
+
return {
|
|
109
|
+
"type": "http.request",
|
|
110
|
+
"body": json.dumps(request.body).encode("utf-8"),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async def send(message):
|
|
114
|
+
if message["type"] == "http.response.start":
|
|
115
|
+
result["status"] = message["status"]
|
|
116
|
+
decoded_headers = []
|
|
117
|
+
for key, value in message.get("headers", []):
|
|
118
|
+
key_str = key.decode("latin-1") if isinstance(key, (bytes, bytearray)) else str(key)
|
|
119
|
+
value_str = (
|
|
120
|
+
value.decode("latin-1") if isinstance(value, (bytes, bytearray)) else str(value)
|
|
121
|
+
)
|
|
122
|
+
decoded_headers.append([key_str, value_str])
|
|
123
|
+
result["headers"] = decoded_headers
|
|
124
|
+
elif message["type"] == "http.response.body":
|
|
125
|
+
body = message.get("body", b"")
|
|
126
|
+
try:
|
|
127
|
+
result["response"] = json.loads(body)
|
|
128
|
+
except Exception:
|
|
129
|
+
result["response"] = body.decode("utf-8", errors="replace")
|
|
130
|
+
|
|
131
|
+
from bazis.core.app import app
|
|
132
|
+
await app(scope, receive, send)
|
|
133
|
+
return result
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
from fastapi import HTTPException, Request, status
|
|
19
|
+
|
|
20
|
+
from .schemas import AsyncRequestPayload
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_request_payload(request: Request) -> AsyncRequestPayload:
|
|
27
|
+
"""Creates a payload for sending to Kafka."""
|
|
28
|
+
body_raw: bytes = request.scope.get("_cached_body") or getattr(request, "_body", b"")
|
|
29
|
+
try:
|
|
30
|
+
body: dict = json.loads(body_raw.decode("utf-8")) if body_raw else {}
|
|
31
|
+
except json.JSONDecodeError:
|
|
32
|
+
body = {}
|
|
33
|
+
|
|
34
|
+
headers: list[tuple[str, str]] = []
|
|
35
|
+
for k, v in request.scope.get("headers", []):
|
|
36
|
+
try:
|
|
37
|
+
k_val, v_val = k.decode(), v.decode()
|
|
38
|
+
if k_val.lower() not in ("x-async-background",):
|
|
39
|
+
headers.append((k_val, v_val))
|
|
40
|
+
except Exception as e:
|
|
41
|
+
logger.exception("Error decoding header: %s", e)
|
|
42
|
+
|
|
43
|
+
headers.append(("x-async-background-internal", "true"))
|
|
44
|
+
|
|
45
|
+
return AsyncRequestPayload(
|
|
46
|
+
path=request.url.path,
|
|
47
|
+
query_string=request.url.query,
|
|
48
|
+
headers=headers,
|
|
49
|
+
request_client=request.client,
|
|
50
|
+
method=request.method,
|
|
51
|
+
type=request.scope["type"],
|
|
52
|
+
http_version=request.scope["http_version"],
|
|
53
|
+
scheme=request.scope["scheme"],
|
|
54
|
+
body=body,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def require_async(request: Request) -> None:
|
|
59
|
+
"""Allow only async-request or internal async-request requests."""
|
|
60
|
+
if request.headers.get("X-Async-Background-Internal", "").lower() == "true":
|
|
61
|
+
return
|
|
62
|
+
if "X-Async-Background" in request.headers:
|
|
63
|
+
return
|
|
64
|
+
raise HTTPException(
|
|
65
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
66
|
+
detail="This endpoint is available only via async request.",
|
|
67
|
+
)
|
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bazis-async-request
|
|
3
|
+
Version: 2.2.0
|
|
4
|
+
Summary: Async Background Requests module for Bazis framework.
|
|
5
|
+
Author-email: Ilya Kharyn <ilya.tt07@gmail.com>
|
|
6
|
+
Maintainer-email: Ilya Kharyn <ilya.tt07@gmail.com>
|
|
7
|
+
Project-URL: Home, https://github.com/ecofuture-tech/bazis-async-request
|
|
8
|
+
Keywords: bazis,django,fastapi,async,background,kafka,framework
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Framework :: Django
|
|
17
|
+
Classifier: Framework :: FastAPI
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: bazis-async-background
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: bazis-test-utils; extra == "test"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: ruff; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# Bazis Async Request
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/bazis-async-request/)
|
|
29
|
+
[](https://pypi.org/project/bazis-async-request/)
|
|
30
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
31
|
+
|
|
32
|
+
An extension package for Bazis that provides background processing for "heavy" HTTP requests on top of bazis-async-background.
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Install the package
|
|
38
|
+
uv add bazis-async-request
|
|
39
|
+
|
|
40
|
+
# Configure environment variables / settings (.env uses BS_ prefix)
|
|
41
|
+
BS_INSTALLED_APPS='["bazis.contrib.async_request", "bazis.contrib.async_background", ...]'
|
|
42
|
+
BS_BAZIS_CONFIG_APPS='["bazis.contrib.async_request", "bazis.contrib.async_background", ...]'
|
|
43
|
+
BS_KAFKA_TASKS='["bazis.contrib.async_request.tasks"]'
|
|
44
|
+
BS_KAFKA_BOOTSTRAP_SERVERS=localhost:9093
|
|
45
|
+
BS_KAFKA_TOPIC_ASYNC_REQUEST=my_app_develop_async_request
|
|
46
|
+
BS_KAFKA_GROUP_ID=my_app_develop
|
|
47
|
+
|
|
48
|
+
# Run consumer in Kubernetes
|
|
49
|
+
python manage.py kafka_consumer_single
|
|
50
|
+
|
|
51
|
+
# Run multiple consumers locally
|
|
52
|
+
python manage.py kafka_consumer_multiple --consumers-count=5
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Table of Contents
|
|
56
|
+
|
|
57
|
+
- [Description](#description)
|
|
58
|
+
- [Requirements](#requirements)
|
|
59
|
+
- [Installation](#installation)
|
|
60
|
+
- [Architecture](#architecture)
|
|
61
|
+
- [Configuration](#configuration)
|
|
62
|
+
- [Environment Variables / Settings](#environment-variables--settings)
|
|
63
|
+
- [Route Registration](#route-registration)
|
|
64
|
+
- [Usage](#usage)
|
|
65
|
+
- [Project-Level Middleware](#project-level-middleware)
|
|
66
|
+
- [Running Consumers](#running-consumers)
|
|
67
|
+
- [Working with Frontend](#working-with-frontend)
|
|
68
|
+
- [Sending a Request](#sending-a-request)
|
|
69
|
+
- [Getting Result via WebSocket](#getting-result-via-websocket)
|
|
70
|
+
- [Getting Result via API](#getting-result-via-api)
|
|
71
|
+
- [Examples](#examples)
|
|
72
|
+
- [License](#license)
|
|
73
|
+
- [Links](#links)
|
|
74
|
+
|
|
75
|
+
## Description
|
|
76
|
+
|
|
77
|
+
**Bazis Async Request** is an extension package for the Bazis framework that allows processing "heavy" requests in the background. The package includes:
|
|
78
|
+
|
|
79
|
+
- **AsyncRequestMiddleware** — project-level middleware for automatic background processing of any request
|
|
80
|
+
- **Kafka Producer** — sending tasks to Kafka queue
|
|
81
|
+
- **Kafka Consumer** — processing tasks from the queue
|
|
82
|
+
- **Redis storage** — storing task execution results
|
|
83
|
+
- **WebSocket notifications** — automatic user notification about task status
|
|
84
|
+
- **API endpoint** — retrieving results by task_id
|
|
85
|
+
|
|
86
|
+
**How it works**: When sending a request with the `X-Async-Request: true` header, the request is not executed immediately but is placed in the Kafka queue. The consumer retrieves the task from the queue, executes it, saves the result in Redis, and sends a notification to the user via WebSocket.
|
|
87
|
+
|
|
88
|
+
**This package requires the installation of `bazis`, `bazis-users`, `bazis-ws` packages and running Kafka and Redis servers.**
|
|
89
|
+
|
|
90
|
+
## Requirements
|
|
91
|
+
|
|
92
|
+
- **Python**: 3.12+
|
|
93
|
+
- **bazis**: latest version
|
|
94
|
+
- **bazis-async-background**: latest version
|
|
95
|
+
- **bazis-ws**: latest version (for WebSocket notifications)
|
|
96
|
+
- **PostgreSQL**: 12+
|
|
97
|
+
- **Redis**: For storing results and caching
|
|
98
|
+
- **Kafka**: For task queue
|
|
99
|
+
|
|
100
|
+
## Installation
|
|
101
|
+
|
|
102
|
+
### Using uv (recommended)
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
uv add bazis-async-request
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Using pip
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pip install bazis-async-request
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Running Tests
|
|
115
|
+
|
|
116
|
+
Run from the project root:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
docker compose -f sample/docker-compose.test.yml up --build --exit-code-from bazis-async-request-pytest --attach bazis-async-request-pytest --attach bazis-async-request-consumer-test
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
This waits for the pytest container to finish and streams logs only from the Python containers, so test completion and output are easy to follow.
|
|
123
|
+
|
|
124
|
+
## Architecture
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
┌─────────────┐
|
|
128
|
+
│ Client │
|
|
129
|
+
└──────┬──────┘
|
|
130
|
+
│ POST + X-Async-Request: true
|
|
131
|
+
▼
|
|
132
|
+
┌─────────────────────┐
|
|
133
|
+
│ API Endpoint │
|
|
134
|
+
│ (AsyncRequestMiddleware)│
|
|
135
|
+
└──────┬──────────────┘
|
|
136
|
+
│ 1. Return task_id (202)
|
|
137
|
+
│ 2. Send to Kafka
|
|
138
|
+
▼
|
|
139
|
+
┌─────────────────────┐
|
|
140
|
+
│ Kafka Topic │
|
|
141
|
+
│ (async_request) │
|
|
142
|
+
└──────┬──────────────┘
|
|
143
|
+
│
|
|
144
|
+
│ Consumer polls
|
|
145
|
+
▼
|
|
146
|
+
┌─────────────────────┐
|
|
147
|
+
│ Kafka Consumer │
|
|
148
|
+
│ (Background Worker)│
|
|
149
|
+
└──────┬──────────────┘
|
|
150
|
+
│ 3. Process request
|
|
151
|
+
│ 4. Save result to Redis
|
|
152
|
+
│ 5. Send WebSocket notification
|
|
153
|
+
▼
|
|
154
|
+
┌─────────────────────┐ ┌──────────────┐
|
|
155
|
+
│ Redis │◄────────┤ WebSocket │
|
|
156
|
+
│ (Results Store) │ │ │
|
|
157
|
+
└──────┬──────────────┘ └───────┬──────┘
|
|
158
|
+
│ │
|
|
159
|
+
│ 6. GET /async_request_ │ 6. Receive notification
|
|
160
|
+
│ response/{task_id}/ │ {status: "completed"}
|
|
161
|
+
▼ ▼
|
|
162
|
+
┌─────────────────────┐ ┌──────────────┐
|
|
163
|
+
│ API Endpoint │────────►│ Client │
|
|
164
|
+
│ (Get Result) │ │ │
|
|
165
|
+
└─────────────────────┘ └──────────────┘
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Configuration
|
|
169
|
+
|
|
170
|
+
### Environment Variables / Settings
|
|
171
|
+
|
|
172
|
+
Add to your `.env` (use `BS_` prefix for Bazis settings) or `settings.py`:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
# Required settings
|
|
176
|
+
INSTALLED_APPS='["bazis.contrib.async_request", "bazis.contrib.async_background", ...]'
|
|
177
|
+
BAZIS_CONFIG_APPS='["bazis.contrib.async_request", "bazis.contrib.async_background", ...]'
|
|
178
|
+
KAFKA_TASKS='["bazis.contrib.async_request.tasks"]'
|
|
179
|
+
|
|
180
|
+
# Kafka settings
|
|
181
|
+
KAFKA_BOOTSTRAP_SERVERS=localhost:9093
|
|
182
|
+
KAFKA_TOPIC_ASYNC_REQUEST=my_app_develop_async_request
|
|
183
|
+
KAFKA_GROUP_ID=my_app_develop
|
|
184
|
+
|
|
185
|
+
# Optional settings
|
|
186
|
+
KAFKA_CONSUMER_LIFETIME_SEC=900 # Consumer lifetime (15 minutes)
|
|
187
|
+
KAFKA_CONSUMER_LIFETIME_JITTER_SEC=180 # Random deviation (3 minutes)
|
|
188
|
+
KAFKA_AUTO_OFFSET_RESET=latest
|
|
189
|
+
KAFKA_ENABLE_AUTO_COMMIT=true
|
|
190
|
+
KAFKA_AUTO_COMMIT_INTERVAL_MS=5000
|
|
191
|
+
KAFKA_LOG_LEVEL=INFO
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
When placing these in a `.env` file, prefix them with `BS_`, for example:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
BS_INSTALLED_APPS='["bazis.contrib.async_request", "bazis.contrib.async_background", ...]'
|
|
198
|
+
BS_BAZIS_CONFIG_APPS='["bazis.contrib.async_request", "bazis.contrib.async_background", ...]'
|
|
199
|
+
BS_KAFKA_TASKS='["bazis.contrib.async_request.tasks"]'
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Parameters**:
|
|
203
|
+
|
|
204
|
+
- `KAFKA_TASKS` — dotted module paths imported by the consumer to register tasks
|
|
205
|
+
- `KAFKA_BOOTSTRAP_SERVERS` — Kafka broker address
|
|
206
|
+
- `KAFKA_TOPIC_ASYNC_REQUEST` — topic for async tasks
|
|
207
|
+
- `KAFKA_GROUP_ID` — consumer group
|
|
208
|
+
- `KAFKA_CONSUMER_LIFETIME_SEC` — consumer working time before restart
|
|
209
|
+
- `KAFKA_CONSUMER_LIFETIME_JITTER_SEC` — random deviation to avoid simultaneous restart
|
|
210
|
+
- `KAFKA_AUTO_OFFSET_RESET` — Kafka auto offset reset policy
|
|
211
|
+
- `KAFKA_ENABLE_AUTO_COMMIT` — Kafka auto-commit toggle
|
|
212
|
+
- `KAFKA_AUTO_COMMIT_INTERVAL_MS` — auto-commit interval in ms
|
|
213
|
+
- `KAFKA_LOG_LEVEL` — log level for consumers
|
|
214
|
+
|
|
215
|
+
### Route Registration
|
|
216
|
+
|
|
217
|
+
Add the route for getting results to your `router.py`:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from bazis.core.routing import BazisRouter
|
|
221
|
+
|
|
222
|
+
router = BazisRouter(prefix='/api/v1')
|
|
223
|
+
|
|
224
|
+
# Register background task results route
|
|
225
|
+
router.register('bazis.contrib.async_background.router')
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
This adds the endpoint: `GET /api/v1/async_background_response/{task_id}/`
|
|
229
|
+
|
|
230
|
+
## Usage
|
|
231
|
+
|
|
232
|
+
### Project-Level Middleware
|
|
233
|
+
|
|
234
|
+
AsyncRequestMiddleware is registered automatically when `bazis.contrib.async_request` is loaded.
|
|
235
|
+
Any request can be moved to background using the `X-Async-Request: true` header.
|
|
236
|
+
|
|
237
|
+
**Location**: `bazis.contrib.async_request.middleware.AsyncRequestMiddleware`
|
|
238
|
+
|
|
239
|
+
### Endpoint-Only Async Request (Dependency)
|
|
240
|
+
|
|
241
|
+
Use a dependency to mark specific endpoints as async-only. Such routes will return `409 Conflict`
|
|
242
|
+
unless the request includes `X-Async-Request` (or an internal background call with
|
|
243
|
+
`X-Async-Request-Internal: true`).
|
|
244
|
+
|
|
245
|
+
**Location**: `bazis.contrib.async_request.dependencies.require_async`
|
|
246
|
+
|
|
247
|
+
#### Attach to a Single Route
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
from fastapi import Depends
|
|
251
|
+
from bazis.contrib.async_request.dependencies import require_async
|
|
252
|
+
|
|
253
|
+
@router.post(
|
|
254
|
+
"/reports/generate/",
|
|
255
|
+
dependencies=[Depends(require_async)],
|
|
256
|
+
)
|
|
257
|
+
async def generate_report(...):
|
|
258
|
+
...
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
#### Attach via Function Signature
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
from fastapi import Depends
|
|
265
|
+
from bazis.contrib.async_request.dependencies import require_async
|
|
266
|
+
|
|
267
|
+
@router.post("/reports/generate/")
|
|
268
|
+
async def generate_report(
|
|
269
|
+
...,
|
|
270
|
+
_async_request: None = Depends(require_async),
|
|
271
|
+
):
|
|
272
|
+
...
|
|
273
|
+
```
|
|
274
|
+
### Running Consumers
|
|
275
|
+
|
|
276
|
+
#### For Kubernetes (one consumer per pod)
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
python manage.py kafka_consumer_single
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Runs one consumer that processes tasks from Kafka. Suitable for horizontal scaling in Kubernetes.
|
|
283
|
+
|
|
284
|
+
#### For Local Development (multiple consumers)
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
python manage.py kafka_consumer_multiple --consumers-count=5
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Runs 5 consumers in separate processes. Suitable for local development or deployment without orchestration.
|
|
291
|
+
|
|
292
|
+
**Parameters**:
|
|
293
|
+
|
|
294
|
+
- `--consumers-count` — number of consumers to run (default: 1)
|
|
295
|
+
|
|
296
|
+
## Working with Frontend
|
|
297
|
+
|
|
298
|
+
### Sending a Request
|
|
299
|
+
|
|
300
|
+
Add the `X-Async-Request: true` header to your request:
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
curl -X POST \
|
|
304
|
+
http://localhost/api/v1/orders/order/ \
|
|
305
|
+
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
|
306
|
+
-H "Content-Type: application/vnd.api+json" \
|
|
307
|
+
-H "X-Async-Request: true" \
|
|
308
|
+
-d '{
|
|
309
|
+
"data": {
|
|
310
|
+
"type": "myapp.order",
|
|
311
|
+
"attributes": {
|
|
312
|
+
"description": "New Order",
|
|
313
|
+
"amount": 1000
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}'
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**Response** (status 202 Accepted):
|
|
320
|
+
|
|
321
|
+
```json
|
|
322
|
+
{
|
|
323
|
+
"data": null,
|
|
324
|
+
"meta": {
|
|
325
|
+
"async_request_id": "371564b0-29a5-457a-aabb-9c43661148a7"
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
If the request has no `Authorization` header, pass a channel name directly:
|
|
331
|
+
|
|
332
|
+
```bash
|
|
333
|
+
X-Async-Request: <channel_name>
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Save the `async_request_id` — this is the task identifier for retrieving the result.
|
|
337
|
+
|
|
338
|
+
### Getting Result via WebSocket
|
|
339
|
+
|
|
340
|
+
After sending the task, connect to WebSocket (requires `bazis-ws` package) and wait for notifications:
|
|
341
|
+
|
|
342
|
+
```javascript
|
|
343
|
+
// Connect to WebSocket (see bazis-ws documentation)
|
|
344
|
+
const ws = new WebSocket(`ws://api.example.com/ws?token=${jwtToken}`);
|
|
345
|
+
|
|
346
|
+
ws.onmessage = (event) => {
|
|
347
|
+
const data = JSON.parse(event.data);
|
|
348
|
+
|
|
349
|
+
if (data.type === 'data') {
|
|
350
|
+
const message = JSON.parse(data.data);
|
|
351
|
+
|
|
352
|
+
if (message.action === 'async_bg') {
|
|
353
|
+
console.log('Task ID:', message.task_id);
|
|
354
|
+
console.log('Status:', message.status);
|
|
355
|
+
|
|
356
|
+
if (message.status === 'completed') {
|
|
357
|
+
// Task completed, get result
|
|
358
|
+
fetchResult(message.task_id);
|
|
359
|
+
} else if (message.status === 'failed') {
|
|
360
|
+
// Task failed
|
|
361
|
+
console.error('Task failed');
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
**WebSocket Notification Format**:
|
|
369
|
+
|
|
370
|
+
```json
|
|
371
|
+
{
|
|
372
|
+
"type": "data",
|
|
373
|
+
"data": "{\"task_id\": \"371564b0-29a5-457a-aabb-9c43661148a7\", \"status\": \"completed\", \"action\": \"async_request\"}"
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
**Task Statuses**:
|
|
378
|
+
|
|
379
|
+
- `completed` — task successfully completed
|
|
380
|
+
- `failed` — task failed with error
|
|
381
|
+
|
|
382
|
+
### Getting Result via API
|
|
383
|
+
|
|
384
|
+
After receiving notification about task completion, request the result:
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
GET /api/v1/async_background_response/{task_id}/
|
|
388
|
+
Authorization: Bearer YOUR_JWT_TOKEN
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**Example Request**:
|
|
392
|
+
|
|
393
|
+
```bash
|
|
394
|
+
curl -X GET \
|
|
395
|
+
http://localhost/api/v1/async_background_response/371564b0-29a5-457a-aabb-9c43661148a7/ \
|
|
396
|
+
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**Success Response** (status 200):
|
|
400
|
+
|
|
401
|
+
```json
|
|
402
|
+
{
|
|
403
|
+
"task_id": "371564b0-29a5-457a-aabb-9c43661148a7",
|
|
404
|
+
"status": 200,
|
|
405
|
+
"endpoint": "/api/v1/orders/order/e7cc4c8c-3ed1-4576-96ad-b3fd7c0b2a5a/",
|
|
406
|
+
"headers": [
|
|
407
|
+
["content-length", "217"],
|
|
408
|
+
["content-type", "application/vnd.api+json"]
|
|
409
|
+
],
|
|
410
|
+
"response": {
|
|
411
|
+
"data": {
|
|
412
|
+
"type": "myapp.order",
|
|
413
|
+
"id": "e7cc4c8c-3ed1-4576-96ad-b3fd7c0b2a5a",
|
|
414
|
+
"attributes": {
|
|
415
|
+
"description": "New Order",
|
|
416
|
+
"amount": 1000,
|
|
417
|
+
"status": "draft"
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
**Error Response** (status 403):
|
|
425
|
+
|
|
426
|
+
```json
|
|
427
|
+
{
|
|
428
|
+
"task_id": "371564b0-29a5-457a-aabb-9c43661148a7",
|
|
429
|
+
"status": 403,
|
|
430
|
+
"endpoint": "/api/v1/orders/order/e7cc4c8c-3ed1-4576-96ad-b3fd7c0b2a5a/",
|
|
431
|
+
"headers": [
|
|
432
|
+
["content-length", "4496"],
|
|
433
|
+
["content-type", "application/json"]
|
|
434
|
+
],
|
|
435
|
+
"response": {
|
|
436
|
+
"errors": [
|
|
437
|
+
{
|
|
438
|
+
"detail": "Permission denied: check access",
|
|
439
|
+
"status": 403
|
|
440
|
+
}
|
|
441
|
+
]
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
## Examples
|
|
447
|
+
|
|
448
|
+
### Complete Example with Frontend
|
|
449
|
+
|
|
450
|
+
**Backend (models.py)**:
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
from bazis.core.models_abstract import DtMixin, UuidMixin, JsonApiMixin
|
|
454
|
+
from django.db import models
|
|
455
|
+
|
|
456
|
+
class Report(DtMixin, UuidMixin, JsonApiMixin):
|
|
457
|
+
"""Report whose generation takes time"""
|
|
458
|
+
title = models.CharField('Title', max_length=255)
|
|
459
|
+
date_from = models.DateField('Date From')
|
|
460
|
+
date_to = models.DateField('Date To')
|
|
461
|
+
status = models.CharField('Status', max_length=50, default='pending')
|
|
462
|
+
result_data = models.JSONField('Report Data', null=True, blank=True)
|
|
463
|
+
|
|
464
|
+
class Meta:
|
|
465
|
+
verbose_name = 'Report'
|
|
466
|
+
verbose_name_plural = 'Reports'
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**Frontend (JavaScript)**:
|
|
470
|
+
|
|
471
|
+
```javascript
|
|
472
|
+
class AsyncReportClient {
|
|
473
|
+
constructor(apiUrl, wsUrl, token) {
|
|
474
|
+
this.apiUrl = apiUrl;
|
|
475
|
+
this.token = token;
|
|
476
|
+
this.ws = null;
|
|
477
|
+
this.pendingTasks = new Map();
|
|
478
|
+
|
|
479
|
+
// Connect to WebSocket
|
|
480
|
+
this.connectWebSocket(wsUrl);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
connectWebSocket(wsUrl) {
|
|
484
|
+
this.ws = new WebSocket(`${wsUrl}?token=${this.token}`);
|
|
485
|
+
|
|
486
|
+
this.ws.onmessage = (event) => {
|
|
487
|
+
const data = JSON.parse(event.data);
|
|
488
|
+
|
|
489
|
+
if (data.type === 'data') {
|
|
490
|
+
const message = JSON.parse(data.data);
|
|
491
|
+
|
|
492
|
+
if (message.action === 'async_bg') {
|
|
493
|
+
this.handleTaskUpdate(message.task_id, message.status);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async createReport(title, dateFrom, dateTo) {
|
|
500
|
+
// Send request to create report
|
|
501
|
+
const response = await fetch(`${this.apiUrl}/reports/report/`, {
|
|
502
|
+
method: 'POST',
|
|
503
|
+
headers: {
|
|
504
|
+
'Authorization': `Bearer ${this.token}`,
|
|
505
|
+
'Content-Type': 'application/vnd.api+json',
|
|
506
|
+
'X-Async-Request': 'true'
|
|
507
|
+
},
|
|
508
|
+
body: JSON.stringify({
|
|
509
|
+
data: {
|
|
510
|
+
type: 'myapp.report',
|
|
511
|
+
attributes: {
|
|
512
|
+
title: title,
|
|
513
|
+
date_from: dateFrom,
|
|
514
|
+
date_to: dateTo
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
})
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const result = await response.json();
|
|
521
|
+
const taskId = result.meta.async_request_id;
|
|
522
|
+
|
|
523
|
+
// Save promise to wait for result
|
|
524
|
+
return new Promise((resolve, reject) => {
|
|
525
|
+
this.pendingTasks.set(taskId, { resolve, reject });
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async handleTaskUpdate(taskId, status) {
|
|
530
|
+
if (!this.pendingTasks.has(taskId)) return;
|
|
531
|
+
|
|
532
|
+
const { resolve, reject } = this.pendingTasks.get(taskId);
|
|
533
|
+
|
|
534
|
+
if (status === 'completed') {
|
|
535
|
+
// Get result
|
|
536
|
+
const result = await this.getResult(taskId);
|
|
537
|
+
this.pendingTasks.delete(taskId);
|
|
538
|
+
resolve(result);
|
|
539
|
+
} else if (status === 'failed') {
|
|
540
|
+
const error = await this.getResult(taskId);
|
|
541
|
+
this.pendingTasks.delete(taskId);
|
|
542
|
+
reject(error);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async getResult(taskId) {
|
|
547
|
+
const response = await fetch(
|
|
548
|
+
`${this.apiUrl}/async_background_response/${taskId}/`,
|
|
549
|
+
{
|
|
550
|
+
headers: {
|
|
551
|
+
'Authorization': `Bearer ${this.token}`
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
);
|
|
555
|
+
return await response.json();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Usage
|
|
560
|
+
const client = new AsyncReportClient(
|
|
561
|
+
'http://api.example.com/api/v1',
|
|
562
|
+
'ws://api.example.com/ws',
|
|
563
|
+
jwtToken
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
// Create report
|
|
567
|
+
client.createReport('Monthly Report', '2024-01-01', '2024-01-31')
|
|
568
|
+
.then(result => {
|
|
569
|
+
console.log('Report created:', result);
|
|
570
|
+
// Display result to user
|
|
571
|
+
})
|
|
572
|
+
.catch(error => {
|
|
573
|
+
console.error('Report generation failed:', error);
|
|
574
|
+
});
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Example for Custom Endpoint
|
|
578
|
+
|
|
579
|
+
**Backend**:
|
|
580
|
+
|
|
581
|
+
```python
|
|
582
|
+
from fastapi import Request, Depends
|
|
583
|
+
from django.contrib.auth import get_user_model
|
|
584
|
+
from bazis.core.routing import BazisRouter
|
|
585
|
+
from bazis.contrib.users.service import get_user_from_token
|
|
586
|
+
import time
|
|
587
|
+
|
|
588
|
+
User = get_user_model()
|
|
589
|
+
router = BazisRouter(tags=["Analytics"])
|
|
590
|
+
|
|
591
|
+
@router.post('/generate-analytics/', response_model=dict)
|
|
592
|
+
async def generate_analytics(
|
|
593
|
+
report_type: str,
|
|
594
|
+
date_from: str,
|
|
595
|
+
date_to: str,
|
|
596
|
+
request: Request,
|
|
597
|
+
user: User = Depends(get_user_from_token)
|
|
598
|
+
):
|
|
599
|
+
"""
|
|
600
|
+
Generate analytics report (long operation)
|
|
601
|
+
"""
|
|
602
|
+
# Simulate long processing
|
|
603
|
+
time.sleep(10)
|
|
604
|
+
|
|
605
|
+
# Generate report
|
|
606
|
+
analytics_data = {
|
|
607
|
+
'report_type': report_type,
|
|
608
|
+
'period': f'{date_from} - {date_to}',
|
|
609
|
+
'total_orders': 1250,
|
|
610
|
+
'revenue': 125000.50,
|
|
611
|
+
'average_order': 100.00
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
'status': 'success',
|
|
616
|
+
'data': analytics_data
|
|
617
|
+
}
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
**Frontend**:
|
|
621
|
+
|
|
622
|
+
```bash
|
|
623
|
+
# Synchronous request (will wait 10 seconds)
|
|
624
|
+
POST /api/v1/generate-analytics/
|
|
625
|
+
Content-Type: application/json
|
|
626
|
+
|
|
627
|
+
{
|
|
628
|
+
"report_type": "sales",
|
|
629
|
+
"date_from": "2024-01-01",
|
|
630
|
+
"date_to": "2024-01-31"
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
# Asynchronous request (returns task_id immediately)
|
|
634
|
+
POST /api/v1/generate-analytics/
|
|
635
|
+
Content-Type: application/json
|
|
636
|
+
X-Async-Request: true
|
|
637
|
+
|
|
638
|
+
{
|
|
639
|
+
"report_type": "sales",
|
|
640
|
+
"date_from": "2024-01-01",
|
|
641
|
+
"date_to": "2024-01-31"
|
|
642
|
+
}
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
### Error Handling Example
|
|
646
|
+
|
|
647
|
+
```python
|
|
648
|
+
from bazis.core.routes_abstract.jsonapi import JsonapiRouteBase
|
|
649
|
+
from django.apps import apps
|
|
650
|
+
|
|
651
|
+
class OrderRouteSet(JsonapiRouteBase):
|
|
652
|
+
model = apps.get_model("myapp", "Order")
|
|
653
|
+
|
|
654
|
+
def hook_before_update(self, item):
|
|
655
|
+
"""Check before update"""
|
|
656
|
+
if item.status == 'draft' and not self.inject.user.is_staff:
|
|
657
|
+
from fastapi import HTTPException
|
|
658
|
+
raise HTTPException(
|
|
659
|
+
status_code=403,
|
|
660
|
+
detail="Permission denied: check access"
|
|
661
|
+
)
|
|
662
|
+
super().hook_before_update(item)
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
**Result with async processing**:
|
|
666
|
+
|
|
667
|
+
```json
|
|
668
|
+
{
|
|
669
|
+
"task_id": "371564b0-29a5-457a-aabb-9c43661148a7",
|
|
670
|
+
"status": 403,
|
|
671
|
+
"response": {
|
|
672
|
+
"errors": [
|
|
673
|
+
{
|
|
674
|
+
"detail": "Permission denied: check access",
|
|
675
|
+
"status": 403
|
|
676
|
+
}
|
|
677
|
+
]
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
## License
|
|
683
|
+
|
|
684
|
+
Apache License 2.0
|
|
685
|
+
|
|
686
|
+
See [LICENSE](LICENSE) file for details.
|
|
687
|
+
|
|
688
|
+
## Links
|
|
689
|
+
|
|
690
|
+
- [Bazis Documentation](https://github.com/ecofuture-tech/bazis) — main repository
|
|
691
|
+
- [Bazis WS](https://github.com/ecofuture-tech/bazis-ws) — WebSocket package
|
|
692
|
+
- [Bazis Async Background Repository](https://github.com/ecofuture-tech/bazis-async-background) — core background framework
|
|
693
|
+
- [Bazis Async Request Repository](https://github.com/ecofuture-tech/bazis-async-request) — package repository
|
|
694
|
+
- [Issue Tracker](https://github.com/ecofuture-tech/bazis-async-request/issues) — report bugs or request features
|
|
695
|
+
- [Apache Kafka](https://kafka.apache.org/) — Kafka documentation
|
|
696
|
+
|
|
697
|
+
## Support
|
|
698
|
+
|
|
699
|
+
If you have questions or issues:
|
|
700
|
+
- Review the [Bazis documentation](https://github.com/ecofuture-tech/bazis)
|
|
701
|
+
- Search [existing issues](https://github.com/ecofuture-tech/bazis-async-request/issues)
|
|
702
|
+
- Create a [new issue](https://github.com/ecofuture-tech/bazis-async-request/issues/new) with detailed information
|
|
703
|
+
|
|
704
|
+
---
|
|
705
|
+
|
|
706
|
+
Made with ❤️ by the Bazis team
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
bazis/contrib/async_request/__init__.py,sha256=tDikAsHzuK1HXEh-EQ8aJzt0uqn1AWzIlR--C-AvhqA,787
|
|
2
|
+
bazis/contrib/async_request/_version.py,sha256=6OGz4a0gjMGlckPyPCNiJDWyFDO-tWO8O_ZNx4ajT2Y,704
|
|
3
|
+
bazis/contrib/async_request/apps.py,sha256=le2m51a2rSh1Yj6m75xWrWWBQqDdjMw3XvCSYx0pajU,949
|
|
4
|
+
bazis/contrib/async_request/middleware.py,sha256=LWKGTLEBPOeeM_ZbARg-_ylmbrDU4ELBemBxsFSsHs8,3731
|
|
5
|
+
bazis/contrib/async_request/schemas.py,sha256=rE1C3fkLZfsZU1DwIJ2o5wPcrpEGGqzxIew1Yg9VdKY,1588
|
|
6
|
+
bazis/contrib/async_request/tasks.py,sha256=04gW2yWJ6T23HQWL1TRgFvRbgcLqg0tf_gxFtK0WjWY,4552
|
|
7
|
+
bazis/contrib/async_request/utils.py,sha256=ql6FDhAN2sjWtqsyWi2Ql_XLWU9R7R4s3SqkZK-OzNw,2319
|
|
8
|
+
bazis_async_request-2.2.0.dist-info/METADATA,sha256=MhK1-s6LTJNoWgnwtoYsIQHbrS9dutjIRfB34OZlO4M,20538
|
|
9
|
+
bazis_async_request-2.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
10
|
+
bazis_async_request-2.2.0.dist-info/top_level.txt,sha256=WgdrPZTZBMG8i_EqxA3vU5qI4ETQ_RsqKqSqsfIApHY,6
|
|
11
|
+
bazis_async_request-2.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bazis
|