Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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