Skip to content

Commit e48100f

Browse files
fix: align WSGI/ASGI test infrastructure and handler with spec (#1513)
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1 parent a38510f commit e48100f

12 files changed

Lines changed: 230 additions & 112 deletions

File tree

requirements/adapter_dev.txt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,4 @@ starlette>=0.19.1,<0.45; python_version<"3.9"
2828
starlette>=0.49.3,<1; python_version>="3.9"
2929
tornado>=6.2,<7; python_version<"3.9"
3030
tornado>=6.5.6,<7; python_version>="3.9"
31-
uvicorn<1 # The oldest version can vary among Python runtime versions
32-
gunicorn>=23.0.0,<24
3331
websocket_client>=1.2.3,<2 # Socket Mode 3rd party implementation

requirements/test_adapter.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
# pip install -r requirements/test_adapter.txt
22
moto>=3,<6 # For AWS tests
3-
docker>=5,<8 # Used by moto
43
boddle>=0.2.9,<0.3 # For Bottle app tests
54
sanic-testing>=0.7

requirements/test_async.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# pip install -r requirements/test_async.txt
22
-r test.txt
33
-r async_dev.txt
4+
asgiref>=3.7.2,<3.8; python_version<"3.9"
5+
asgiref>=3.8,<4; python_version>="3.9"
46
pytest-asyncio<2;

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: 7 additions & 4 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.raw_headers: List[Tuple[bytes, bytes]] = [
12-
(bytes(key, ENCODING), bytes(value[0], ENCODING)) for key, value in headers.items()
13-
]
14-
self.raw_headers.append((b"content-length", bytes(str(len(body)), ENCODING)))
1511
self.body: bytes = bytes(body, ENCODING)
12+
self.raw_headers: List[Tuple[bytes, bytes]] = []
13+
for key, values in headers.items():
14+
if key.lower() == "content-length":
15+
continue
16+
for v in values:
17+
self.raw_headers.append((bytes(key, ENCODING), bytes(v, 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 {

slack_bolt/adapter/wsgi/handler.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
from typing import Any, Callable, Dict, Iterable, List, Tuple
1+
from typing import TYPE_CHECKING, Iterable
22

33
from slack_bolt import App
4+
5+
if TYPE_CHECKING:
6+
from wsgiref.types import StartResponse, WSGIEnvironment
7+
48
from slack_bolt.adapter.wsgi.http_request import WsgiHttpRequest
59
from slack_bolt.adapter.wsgi.http_response import WsgiHttpResponse
610
from slack_bolt.request import BoltRequest
@@ -69,14 +73,17 @@ def _get_http_response(self, request: WsgiHttpRequest) -> WsgiHttpResponse:
6973

7074
def __call__(
7175
self,
72-
environ: Dict[str, Any],
73-
start_response: Callable[[str, List[Tuple[str, str]]], None],
76+
environ: "WSGIEnvironment",
77+
start_response: "StartResponse",
7478
) -> Iterable[bytes]:
7579
request = WsgiHttpRequest(environ)
76-
if "HTTP" in request.protocol:
80+
if request.protocol.startswith("HTTP"):
7781
response: WsgiHttpResponse = self._get_http_response(
7882
request=request,
7983
)
80-
start_response(response.status, response.get_headers())
81-
return response.get_body()
82-
raise TypeError(f"Unsupported SERVER_PROTOCOL: {request.protocol}")
84+
else:
85+
response = WsgiHttpResponse(
86+
status=400, headers={"content-type": ["text/plain;charset=utf-8"]}, body="Bad Request"
87+
)
88+
start_response(response.status, response.get_headers())
89+
return response.get_body()

slack_bolt/adapter/wsgi/http_request.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from typing import Any, Dict, Sequence, Union
1+
from typing import TYPE_CHECKING, Dict, Sequence, Union
2+
3+
if TYPE_CHECKING:
4+
from wsgiref.types import WSGIEnvironment
25

36
from .internals import ENCODING
47

@@ -12,7 +15,7 @@ class WsgiHttpRequest:
1215

1316
__slots__ = ("method", "path", "query_string", "protocol", "environ")
1417

15-
def __init__(self, environ: Dict[str, Any]):
18+
def __init__(self, environ: "WSGIEnvironment"):
1619
self.method: str = environ.get("REQUEST_METHOD", "GET")
1720
self.path: str = environ.get("PATH_INFO", "")
1821
self.query_string: str = environ.get("QUERY_STRING", "")
@@ -33,5 +36,5 @@ def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
3336
def get_body(self) -> str:
3437
if "wsgi.input" not in self.environ:
3538
return ""
36-
content_length = int(self.environ.get("CONTENT_LENGTH", 0))
39+
content_length = int(self.environ.get("CONTENT_LENGTH") or 0)
3740
return self.environ["wsgi.input"].read(content_length).decode(ENCODING)

slack_bolt/adapter/wsgi/http_response.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from http import HTTPStatus
2-
from typing import Dict, Iterable, List, Sequence, Tuple
2+
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
33

44
from .internals import ENCODING
55

@@ -13,18 +13,19 @@ class WsgiHttpResponse:
1313

1414
__slots__ = ("status", "_headers", "_body")
1515

16-
def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""):
16+
def __init__(self, status: int, headers: Optional[Dict[str, Sequence[str]]] = None, body: str = ""):
1717
_status = HTTPStatus(status)
1818
self.status = f"{_status.value} {_status.phrase}"
19-
self._headers = headers
19+
self._headers = headers or {}
2020
self._body = bytes(body, ENCODING)
2121

2222
def get_headers(self) -> List[Tuple[str, str]]:
2323
headers: List[Tuple[str, str]] = []
24-
for key, value in self._headers.items():
24+
for key, values in self._headers.items():
2525
if key.lower() == "content-length":
2626
continue
27-
headers.append((key, value[0]))
27+
for v in values:
28+
headers.append((key, v))
2829

2930
headers.append(("content-length", str(len(self._body))))
3031
return headers

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(

0 commit comments

Comments
 (0)