Skip to content
2 changes: 0 additions & 2 deletions requirements/adapter_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,4 @@ starlette>=0.19.1,<0.45; python_version<"3.9"
starlette>=0.49.3,<1; python_version>="3.9"
tornado>=6.2,<7; python_version<"3.9"
tornado>=6.5.6,<7; python_version>="3.9"
uvicorn<1 # The oldest version can vary among Python runtime versions
gunicorn>=23.0.0,<24
websocket_client>=1.2.3,<2 # Socket Mode 3rd party implementation
1 change: 0 additions & 1 deletion requirements/test_adapter.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# pip install -r requirements/test_adapter.txt
moto>=3,<6 # For AWS tests
docker>=5,<8 # Used by moto
boddle>=0.2.9,<0.3 # For Bottle app tests
sanic-testing>=0.7
2 changes: 2 additions & 0 deletions requirements/test_async.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# pip install -r requirements/test_async.txt
-r test.txt
-r async_dev.txt
asgiref>=3.7.2,<3.8; python_version<"3.9"
asgiref>=3.8,<4; python_version>="3.9"
pytest-asyncio<2;
20 changes: 9 additions & 11 deletions slack_bolt/adapter/asgi/base_handler.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Callable, Dict, Union
from typing import Callable, Union

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

async def _handle_lifespan(self, receive: Callable) -> Dict[str, str]:
while True:
lifespan = await receive()
if lifespan["type"] == "lifespan.startup":
"""Do something before startup"""
return {"type": "lifespan.startup.complete"}
if lifespan["type"] == "lifespan.shutdown":
"""Do something before shutdown"""
return {"type": "lifespan.shutdown.complete"}
async def _handle_lifespan(self, receive: Callable, send: Callable) -> None:
message = await receive()
if message["type"] == "lifespan.startup":
await send({"type": "lifespan.startup.complete"})
message = await receive()
if message["type"] == "lifespan.shutdown":
await send({"type": "lifespan.shutdown.complete"})

async def __call__(self, scope: scope_type, receive: Callable, send: Callable) -> None:
if scope["type"] == "http":
Expand All @@ -66,6 +64,6 @@ async def __call__(self, scope: scope_type, receive: Callable, send: Callable) -
await send(response.get_response_body())
return
if scope["type"] == "lifespan":
await send(await self._handle_lifespan(receive))
await self._handle_lifespan(receive, send)
return
raise TypeError(f"Unsupported scope type: {scope['type']!r}")
11 changes: 7 additions & 4 deletions slack_bolt/adapter/asgi/http_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ class AsgiHttpResponse:

def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""):
self.status: int = status
self.raw_headers: List[Tuple[bytes, bytes]] = [
(bytes(key, ENCODING), bytes(value[0], ENCODING)) for key, value in headers.items()
]
self.raw_headers.append((b"content-length", bytes(str(len(body)), ENCODING)))
self.body: bytes = bytes(body, ENCODING)
self.raw_headers: List[Tuple[bytes, bytes]] = []
for key, values in headers.items():
if key.lower() == "content-length":
continue
for v in values:
self.raw_headers.append((bytes(key, ENCODING), bytes(v, ENCODING)))
self.raw_headers.append((b"content-length", bytes(str(len(self.body)), ENCODING)))

def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]:
return {
Expand Down
21 changes: 14 additions & 7 deletions slack_bolt/adapter/wsgi/handler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from typing import Any, Callable, Dict, Iterable, List, Tuple
from typing import TYPE_CHECKING, Iterable

from slack_bolt import App

if TYPE_CHECKING:
from wsgiref.types import StartResponse, WSGIEnvironment

from slack_bolt.adapter.wsgi.http_request import WsgiHttpRequest
from slack_bolt.adapter.wsgi.http_response import WsgiHttpResponse
from slack_bolt.request import BoltRequest
Expand Down Expand Up @@ -69,14 +73,17 @@ def _get_http_response(self, request: WsgiHttpRequest) -> WsgiHttpResponse:

def __call__(
self,
environ: Dict[str, Any],
start_response: Callable[[str, List[Tuple[str, str]]], None],
environ: "WSGIEnvironment",
start_response: "StartResponse",
) -> Iterable[bytes]:
request = WsgiHttpRequest(environ)
if "HTTP" in request.protocol:
if request.protocol.startswith("HTTP"):
response: WsgiHttpResponse = self._get_http_response(
request=request,
)
start_response(response.status, response.get_headers())
return response.get_body()
raise TypeError(f"Unsupported SERVER_PROTOCOL: {request.protocol}")
else:
response = WsgiHttpResponse(
status=400, headers={"content-type": ["text/plain;charset=utf-8"]}, body="Bad Request"
)
start_response(response.status, response.get_headers())
return response.get_body()
9 changes: 6 additions & 3 deletions slack_bolt/adapter/wsgi/http_request.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from typing import Any, Dict, Sequence, Union
from typing import TYPE_CHECKING, Dict, Sequence, Union

if TYPE_CHECKING:
from wsgiref.types import WSGIEnvironment

from .internals import ENCODING

Expand All @@ -12,7 +15,7 @@ class WsgiHttpRequest:

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

def __init__(self, environ: Dict[str, Any]):
def __init__(self, environ: "WSGIEnvironment"):
self.method: str = environ.get("REQUEST_METHOD", "GET")
self.path: str = environ.get("PATH_INFO", "")
self.query_string: str = environ.get("QUERY_STRING", "")
Expand All @@ -33,5 +36,5 @@ def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
def get_body(self) -> str:
if "wsgi.input" not in self.environ:
return ""
content_length = int(self.environ.get("CONTENT_LENGTH", 0))
content_length = int(self.environ.get("CONTENT_LENGTH") or 0)
return self.environ["wsgi.input"].read(content_length).decode(ENCODING)
11 changes: 6 additions & 5 deletions slack_bolt/adapter/wsgi/http_response.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from http import HTTPStatus
from typing import Dict, Iterable, List, Sequence, Tuple
from typing import Dict, Iterable, List, Optional, Sequence, Tuple

from .internals import ENCODING

Expand All @@ -13,18 +13,19 @@ class WsgiHttpResponse:

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

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

def get_headers(self) -> List[Tuple[str, str]]:
headers: List[Tuple[str, str]] = []
for key, value in self._headers.items():
for key, values in self._headers.items():
if key.lower() == "content-length":
continue
headers.append((key, value[0]))
for v in values:
headers.append((key, v))

headers.append(("content-length", str(len(self._body))))
return headers
Expand Down
53 changes: 53 additions & 0 deletions tests/adapter_tests/asgi/test_asgi_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,59 @@ async def test_url_verification(self):
assert response.headers.get("content-type") == "application/json;charset=utf-8"
assert_auth_test_count(self, 1)

@pytest.mark.asyncio
async def test_content_length_multibyte_body(self):
app = App(
client=self.web_client,
signing_secret=self.signing_secret,
)

def command_handler(ack):
ack(text="Hello ☃") # snowman is 3 bytes in UTF-8

app.command("/hello-world")(command_handler)

body = (
"token=verification_token"
"&team_id=T111"
"&team_domain=test-domain"
"&channel_id=C111"
"&channel_name=random"
"&user_id=W111"
"&user_name=primary-owner"
"&command=%2Fhello-world"
"&text=Hi"
"&enterprise_id=E111"
"&enterprise_name=Org+Name"
"&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx"
"&trigger_id=111.111.xxx"
)

headers = self.build_raw_headers(str(int(time())), body)

asgi_server = AsgiTestServer(SlackRequestHandler(app))
response = await asgi_server.http("POST", headers, body)

assert response.status_code == 200
content_length = int(response.headers.get("content-length"))
actual_bytes = len(response.body.encode("utf-8"))
assert content_length == actual_bytes

@pytest.mark.asyncio
async def test_multi_value_headers(self):
from slack_bolt.adapter.asgi.http_response import AsgiHttpResponse

headers = {
"set-cookie": ["cookie1=value1; Path=/", "cookie2=value2; Path=/"],
"content-type": ["text/html; charset=utf-8"],
}
response = AsgiHttpResponse(status=200, headers=headers, body="OK")

set_cookie_headers = [(name, value) for name, value in response.raw_headers if name == b"set-cookie"]
assert len(set_cookie_headers) == 2
assert set_cookie_headers[0] == (b"set-cookie", b"cookie1=value1; Path=/")
assert set_cookie_headers[1] == (b"set-cookie", b"cookie2=value2; Path=/")

@pytest.mark.asyncio
async def test_unsupported_method(self):
app = App(
Expand Down
19 changes: 19 additions & 0 deletions tests/adapter_tests/asgi/test_asgi_lifespan.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pytest

from asgiref.testing import ApplicationCommunicator
from slack_sdk.signature import SignatureVerifier
from slack_sdk.web import WebClient

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

@pytest.mark.asyncio
async def test_full_lifespan_cycle(self):
app = App(
client=self.web_client,
signing_secret=self.signing_secret,
)

scope = {"type": "lifespan", "asgi": {"version": "3.0", "spec_version": "2.3"}}
communicator = ApplicationCommunicator(SlackRequestHandler(app), scope)

await communicator.send_input({"type": "lifespan.startup"})
startup_response = await communicator.receive_output(timeout=1)
assert startup_response["type"] == "lifespan.startup.complete"

await communicator.send_input({"type": "lifespan.shutdown"})
shutdown_response = await communicator.receive_output(timeout=1)
assert shutdown_response["type"] == "lifespan.shutdown.complete"

@pytest.mark.asyncio
async def test_failed_event(self):
app = App(
Expand Down
90 changes: 50 additions & 40 deletions tests/mock_asgi_server.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,44 @@
from typing import Iterable, Tuple, Union
from typing import Iterable, Tuple

from asgiref.testing import ApplicationCommunicator

from slack_bolt.adapter.asgi.base_handler import BaseSlackRequestHandler

ENCODING = "utf-8"


class AsgiTestServerResponse:
def __init__(self):
self.status_code: int = None
self._headers: Iterable[Tuple[bytes, bytes]] = []
self._body: bytearray = bytearray(b"")
def __init__(
self,
status_code: int,
headers: Iterable[Tuple[bytes, bytes]] = (),
body: bytes = b"",
):
self.status_code = status_code
self._headers = headers
self._body = body

@property
def body(self):
def body(self) -> str:
return self._body.decode(ENCODING)

@property
def headers(self):
return {header[0].decode(ENCODING): header[1].decode(ENCODING) for header in self._headers}
def headers(self) -> dict:
result = {}
for header in self._headers:
key = header[0].decode(ENCODING)
if key not in result:
result[key] = header[1].decode(ENCODING)
return result

def get_headers_list(self, name: str) -> list:
return [header[1].decode(ENCODING) for header in self._headers if header[0].decode(ENCODING) == name]


class AsgiTestServerLifespanResponse:
def __init__(self):
self.type: str = None
self.message: str = ""
def __init__(self, type: str, message: str = ""):
self.type = type
self.message = message


class AsgiTestServer:
Expand Down Expand Up @@ -61,22 +77,17 @@ async def http(
},
)

async def receive():
return {"type": "http.request", "body": bytes(body, ENCODING), "more_body": False}
communicator = ApplicationCommunicator(self.asgi_app, scope)
await communicator.send_input({"type": "http.request", "body": bytes(body, ENCODING), "more_body": False})

response = AsgiTestServerResponse()
response_start = await communicator.receive_output(timeout=1)
response_body = await communicator.receive_output(timeout=1)

async def send(event):
if event["type"] == "http.response.start":
response.status_code = event["status"]
response._headers = event["headers"]
elif event["type"] == "http.response.body":
response._body.extend(event["body"])
else:
raise TypeError(f"Sent type {event['type']} in response {event} is not valid")

await self.asgi_app(scope, receive, send)
return response
return AsgiTestServerResponse(
status_code=response_start["status"],
headers=response_start.get("headers", []),
body=response_body.get("body", b""),
)

async def lifespan(self, event: str) -> AsgiTestServerLifespanResponse:
"""This implements the server side behavior of the lifespan event
Expand All @@ -92,17 +103,20 @@ async def lifespan(self, event: str) -> AsgiTestServerLifespanResponse:
},
)

async def receive():
return {"type": f"lifespan.{event}"}
communicator = ApplicationCommunicator(self.asgi_app, scope)
await communicator.send_input({"type": f"lifespan.{event}"})

response = AsgiTestServerLifespanResponse()
result = await communicator.receive_output(timeout=1)

async def send(event: dict):
response.type = event["type"]
response.message = event.get("message", "")
# Send shutdown so the handler exits cleanly
if event == "startup":
await communicator.send_input({"type": "lifespan.shutdown"})
await communicator.receive_output(timeout=1)

await self.asgi_app(scope, receive, send)
return response
return AsgiTestServerLifespanResponse(
type=result["type"],
message=result.get("message", ""),
)

async def websocket(self) -> None:
"""This is not implemented"""
Expand All @@ -113,10 +127,6 @@ async def websocket(self) -> None:
},
)

async def receive():
return {}

async def send(event: dict):
print(event)

await self.asgi_app(scope, receive, send)
communicator = ApplicationCommunicator(self.asgi_app, scope)
await communicator.send_input({})
await communicator.receive_output(timeout=1)
Loading