Skip to content

Commit 16a467f

Browse files
Improve asgi adapter behavior
1 parent 3d36fac commit 16a467f

5 files changed

Lines changed: 102 additions & 15 deletions

File tree

slack_bolt/adapter/asgi/base_handler.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Callable, Dict, Union
1+
from typing import Callable, Union
22

33
from .http_request import AsgiHttpRequest
44
from .http_response import AsgiHttpResponse
@@ -47,15 +47,13 @@ async def _get_http_response(self, method: str, path: str, request: AsgiHttpRequ
4747
return AsgiHttpResponse(status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body)
4848
return AsgiHttpResponse(status=404, headers={"content-type": ["text/plain;charset=utf-8"]}, body="Not Found")
4949

50-
async def _handle_lifespan(self, receive: Callable) -> Dict[str, str]:
51-
while True:
52-
lifespan = await receive()
53-
if lifespan["type"] == "lifespan.startup":
54-
"""Do something before startup"""
55-
return {"type": "lifespan.startup.complete"}
56-
if lifespan["type"] == "lifespan.shutdown":
57-
"""Do something before shutdown"""
58-
return {"type": "lifespan.shutdown.complete"}
50+
async def _handle_lifespan(self, receive: Callable, send: Callable) -> None:
51+
message = await receive()
52+
if message["type"] == "lifespan.startup":
53+
await send({"type": "lifespan.startup.complete"})
54+
message = await receive()
55+
if message["type"] == "lifespan.shutdown":
56+
await send({"type": "lifespan.shutdown.complete"})
5957

6058
async def __call__(self, scope: scope_type, receive: Callable, send: Callable) -> None:
6159
if scope["type"] == "http":
@@ -66,6 +64,6 @@ async def __call__(self, scope: scope_type, receive: Callable, send: Callable) -
6664
await send(response.get_response_body())
6765
return
6866
if scope["type"] == "lifespan":
69-
await send(await self._handle_lifespan(receive))
67+
await self._handle_lifespan(receive, send)
7068
return
7169
raise TypeError(f"Unsupported scope type: {scope['type']!r}")

slack_bolt/adapter/asgi/http_response.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ class AsgiHttpResponse:
88

99
def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""):
1010
self.status: int = status
11+
self.body: bytes = bytes(body, ENCODING)
1112
self.raw_headers: List[Tuple[bytes, bytes]] = [
12-
(bytes(key, ENCODING), bytes(value[0], ENCODING)) for key, value in headers.items()
13+
(bytes(key, ENCODING), bytes(v, ENCODING))
14+
for key, values in headers.items()
15+
if key.lower() != "content-length"
16+
for v in values
1317
]
14-
self.raw_headers.append((b"content-length", bytes(str(len(body)), ENCODING)))
15-
self.body: bytes = bytes(body, ENCODING)
18+
self.raw_headers.append((b"content-length", bytes(str(len(self.body)), ENCODING)))
1619

1720
def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]:
1821
return {

tests/adapter_tests/asgi/test_asgi_http.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,59 @@ async def test_url_verification(self):
223223
assert response.headers.get("content-type") == "application/json;charset=utf-8"
224224
assert_auth_test_count(self, 1)
225225

226+
@pytest.mark.asyncio
227+
async def test_content_length_multibyte_body(self):
228+
app = App(
229+
client=self.web_client,
230+
signing_secret=self.signing_secret,
231+
)
232+
233+
def command_handler(ack):
234+
ack(text="Hello ☃") # snowman is 3 bytes in UTF-8
235+
236+
app.command("/hello-world")(command_handler)
237+
238+
body = (
239+
"token=verification_token"
240+
"&team_id=T111"
241+
"&team_domain=test-domain"
242+
"&channel_id=C111"
243+
"&channel_name=random"
244+
"&user_id=W111"
245+
"&user_name=primary-owner"
246+
"&command=%2Fhello-world"
247+
"&text=Hi"
248+
"&enterprise_id=E111"
249+
"&enterprise_name=Org+Name"
250+
"&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx"
251+
"&trigger_id=111.111.xxx"
252+
)
253+
254+
headers = self.build_raw_headers(str(int(time())), body)
255+
256+
asgi_server = AsgiTestServer(SlackRequestHandler(app))
257+
response = await asgi_server.http("POST", headers, body)
258+
259+
assert response.status_code == 200
260+
content_length = int(response.headers.get("content-length"))
261+
actual_bytes = len(response.body.encode("utf-8"))
262+
assert content_length == actual_bytes
263+
264+
@pytest.mark.asyncio
265+
async def test_multi_value_headers(self):
266+
from slack_bolt.adapter.asgi.http_response import AsgiHttpResponse
267+
268+
headers = {
269+
"set-cookie": ["cookie1=value1; Path=/", "cookie2=value2; Path=/"],
270+
"content-type": ["text/html; charset=utf-8"],
271+
}
272+
response = AsgiHttpResponse(status=200, headers=headers, body="OK")
273+
274+
set_cookie_headers = [(name, value) for name, value in response.raw_headers if name == b"set-cookie"]
275+
assert len(set_cookie_headers) == 2
276+
assert set_cookie_headers[0] == (b"set-cookie", b"cookie1=value1; Path=/")
277+
assert set_cookie_headers[1] == (b"set-cookie", b"cookie2=value2; Path=/")
278+
226279
@pytest.mark.asyncio
227280
async def test_unsupported_method(self):
228281
app = App(

tests/adapter_tests/asgi/test_asgi_lifespan.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22

3+
from asgiref.testing import ApplicationCommunicator
34
from slack_sdk.signature import SignatureVerifier
45
from slack_sdk.web import WebClient
56

@@ -59,6 +60,24 @@ async def test_shutdown(self):
5960
assert response.type == "lifespan.shutdown.complete"
6061
assert response.message == ""
6162

63+
@pytest.mark.asyncio
64+
async def test_full_lifespan_cycle(self):
65+
app = App(
66+
client=self.web_client,
67+
signing_secret=self.signing_secret,
68+
)
69+
70+
scope = {"type": "lifespan", "asgi": {"version": "3.0", "spec_version": "2.3"}}
71+
communicator = ApplicationCommunicator(SlackRequestHandler(app), scope)
72+
73+
await communicator.send_input({"type": "lifespan.startup"})
74+
startup_response = await communicator.receive_output(timeout=1)
75+
assert startup_response["type"] == "lifespan.startup.complete"
76+
77+
await communicator.send_input({"type": "lifespan.shutdown"})
78+
shutdown_response = await communicator.receive_output(timeout=1)
79+
assert shutdown_response["type"] == "lifespan.shutdown.complete"
80+
6281
@pytest.mark.asyncio
6382
async def test_failed_event(self):
6483
app = App(

tests/mock_asgi_server.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,15 @@ def body(self) -> str:
2424

2525
@property
2626
def headers(self) -> dict:
27-
return {header[0].decode(ENCODING): header[1].decode(ENCODING) for header in self._headers}
27+
result = {}
28+
for header in self._headers:
29+
key = header[0].decode(ENCODING)
30+
if key not in result:
31+
result[key] = header[1].decode(ENCODING)
32+
return result
33+
34+
def get_headers_list(self, name: str) -> list:
35+
return [header[1].decode(ENCODING) for header in self._headers if header[0].decode(ENCODING) == name]
2836

2937

3038
class AsgiTestServerLifespanResponse:
@@ -99,6 +107,12 @@ async def lifespan(self, event: str) -> AsgiTestServerLifespanResponse:
99107
await communicator.send_input({"type": f"lifespan.{event}"})
100108

101109
result = await communicator.receive_output(timeout=1)
110+
111+
# Send shutdown so the handler exits cleanly
112+
if event == "startup":
113+
await communicator.send_input({"type": "lifespan.shutdown"})
114+
await communicator.receive_output(timeout=1)
115+
102116
return AsgiTestServerLifespanResponse(
103117
type=result["type"],
104118
message=result.get("message", ""),

0 commit comments

Comments
 (0)