From 6bc4f9aa225ba253e52f25b567a116fbbc431c0f Mon Sep 17 00:00:00 2001 From: Voronin Sergei Date: Sun, 31 May 2026 23:36:42 +1200 Subject: [PATCH 1/3] Add Quart async adapter --- examples/quart/async_app.py | 32 ++ examples/quart/async_oauth_app.py | 47 +++ examples/quart/requirements.txt | 1 + requirements/adapter_dev.txt | 1 + slack_bolt/adapter/quart/__init__.py | 5 + slack_bolt/adapter/quart/async_handler.py | 76 ++++ tests/adapter_tests_async/test_async_quart.py | 361 ++++++++++++++++++ 7 files changed, 523 insertions(+) create mode 100644 examples/quart/async_app.py create mode 100644 examples/quart/async_oauth_app.py create mode 100644 examples/quart/requirements.txt create mode 100644 slack_bolt/adapter/quart/__init__.py create mode 100644 slack_bolt/adapter/quart/async_handler.py create mode 100644 tests/adapter_tests_async/test_async_quart.py diff --git a/examples/quart/async_app.py b/examples/quart/async_app.py new file mode 100644 index 000000000..ef6f035f5 --- /dev/null +++ b/examples/quart/async_app.py @@ -0,0 +1,32 @@ +import os +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.quart import AsyncSlackRequestHandler + +app = AsyncApp() +app_handler = AsyncSlackRequestHandler(app) + + +@app.event("app_mention") +async def handle_app_mentions(body, say, logger): + logger.info(body) + await say("What's up?") + + +from quart import Quart, request + +api = Quart(__name__) + + +@api.post("/slack/events") +async def endpoint(): + return await app_handler.handle(request) + + +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) + + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# hypercorn async_app:api --reload --bind 0.0.0.0:3000 diff --git a/examples/quart/async_oauth_app.py b/examples/quart/async_oauth_app.py new file mode 100644 index 000000000..b174b4671 --- /dev/null +++ b/examples/quart/async_oauth_app.py @@ -0,0 +1,47 @@ +import os +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.quart import AsyncSlackRequestHandler + +app = AsyncApp() +app_handler = AsyncSlackRequestHandler(app) + + +@app.event("app_mention") +async def handle_app_mentions(body, say, logger): + logger.info(body) + await say("What's up?") + + +from quart import Quart, request + +api = Quart(__name__) + + +@api.post("/slack/events") +async def endpoint(): + return await app_handler.handle(request) + + +@api.get("/slack/install") +async def install(): + return await app_handler.handle(request) + + +@api.get("/slack/oauth_redirect") +async def oauth_redirect(): + return await app_handler.handle(request) + + +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) + + +# pip install -r requirements.txt + +# # -- OAuth flow -- # +# export SLACK_SIGNING_SECRET=*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write + +# hypercorn async_oauth_app:api --reload --bind 0.0.0.0:3000 diff --git a/examples/quart/requirements.txt b/examples/quart/requirements.txt new file mode 100644 index 000000000..39023e58d --- /dev/null +++ b/examples/quart/requirements.txt @@ -0,0 +1 @@ +quart>=0.20,<1 diff --git a/requirements/adapter_dev.txt b/requirements/adapter_dev.txt index ea924f5bb..a82de871f 100644 --- a/requirements/adapter_dev.txt +++ b/requirements/adapter_dev.txt @@ -13,6 +13,7 @@ falcon>=2,<4; python_version<"3.9" falcon>=4.2.0,<5; python_version>="3.9" fastapi>=0.70.0,<1 Flask>=1,<4 +quart>=0.20,<1; python_version>="3.9" Werkzeug>=2,<3; python_version<"3.9" Werkzeug>=3.1.8,<4; python_version>="3.9" pyramid>=1,<3 diff --git a/slack_bolt/adapter/quart/__init__.py b/slack_bolt/adapter/quart/__init__.py new file mode 100644 index 000000000..3805e26ed --- /dev/null +++ b/slack_bolt/adapter/quart/__init__.py @@ -0,0 +1,5 @@ +from .async_handler import AsyncSlackRequestHandler + +__all__ = [ + "AsyncSlackRequestHandler", +] diff --git a/slack_bolt/adapter/quart/async_handler.py b/slack_bolt/adapter/quart/async_handler.py new file mode 100644 index 000000000..5bdf7d7c4 --- /dev/null +++ b/slack_bolt/adapter/quart/async_handler.py @@ -0,0 +1,76 @@ +from typing import Any, Dict, Optional, cast + +from quart import Request, Response, make_response + +from slack_bolt import BoltResponse +from slack_bolt.async_app import AsyncApp, AsyncBoltRequest +from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow + + +async def to_async_bolt_request( + req: Request, + addition_context_properties: Optional[Dict[str, Any]] = None, +) -> AsyncBoltRequest: + request = AsyncBoltRequest( + body=cast(str, await req.get_data(as_text=True)), + query=req.query_string.decode("utf-8"), + headers=req.headers, # type: ignore[arg-type] + ) + + if addition_context_properties is not None: + for k, v in addition_context_properties.items(): + request.context[k] = v + + return request + + +async def to_quart_response(bolt_resp: BoltResponse) -> Response: + resp = cast(Response, await make_response(bolt_resp.body, bolt_resp.status)) + for k, values in bolt_resp.headers.items(): + if k == "set-cookie": + continue + if k.lower() == "content-type" and resp.headers.get("content-type") is not None: + resp.headers.pop("content-type") + for v in values: + resp.headers.add_header(k, v) + + for cookie in bolt_resp.cookies(): + for name, c in cookie.items(): + resp.set_cookie( + key=name, + value=c.value, + max_age=c.get("max-age"), + expires=c.get("expires"), + path=c.get("path"), + domain=c.get("domain"), + secure=True, + httponly=True, + ) + + return resp + + +class AsyncSlackRequestHandler: + def __init__(self, app: AsyncApp): + self.app = app + + async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response: + if req.method == "GET": + if self.app.oauth_flow is not None: + oauth_flow: AsyncOAuthFlow = self.app.oauth_flow + if req.path == oauth_flow.install_path: + bolt_resp = await oauth_flow.handle_installation( + await to_async_bolt_request(req, addition_context_properties) + ) + return await to_quart_response(bolt_resp) + elif req.path == oauth_flow.redirect_uri_path: + bolt_resp = await oauth_flow.handle_callback( + await to_async_bolt_request(req, addition_context_properties) + ) + return await to_quart_response(bolt_resp) + + elif req.method == "POST": + bolt_resp = await self.app.async_dispatch(await to_async_bolt_request(req, addition_context_properties)) + return await to_quart_response(bolt_resp) + + return cast(Response, await make_response("Not Found", 404)) diff --git a/tests/adapter_tests_async/test_async_quart.py b/tests/adapter_tests_async/test_async_quart.py new file mode 100644 index 000000000..53dd91a22 --- /dev/null +++ b/tests/adapter_tests_async/test_async_quart.py @@ -0,0 +1,361 @@ +import json +import sys +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from tests.mock_web_api_server import ( + assert_auth_test_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +pytestmark = pytest.mark.skipif(sys.version_info < (3, 9), reason="Quart requires Python 3.9+") + +if sys.version_info >= (3, 9): + from slack_bolt.adapter.quart.async_handler import AsyncSlackRequestHandler + from quart import Quart, request + + +class TestAsyncStateStore(AsyncOAuthStateStore): + async def async_issue(self, *args, **kwargs) -> str: + return "uuid4-value" + + async def async_consume(self, state: str) -> bool: + return state == "uuid4-value" + + +class TestAsyncQuart: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": content_type, + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + @pytest.mark.asyncio + async def test_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + api = Quart(__name__) + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(): + return await app_handler.handle(request) + + client = api.test_client() + response = await client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_events_with_additional_context_properties(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + observed_context = {} + + async def event_handler(context): + observed_context["custom_value"] = context["custom_value"] + + app.event("app_mention")(event_handler) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + api = Quart(__name__) + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(): + return await app_handler.handle(request, {"custom_value": "quart"}) + + client = api.test_client() + response = await client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert observed_context["custom_value"] == "quart" + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_shortcuts(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def shortcut_handler(ack): + await ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + api = Quart(__name__) + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(): + return await app_handler.handle(request) + + client = api.test_client() + response = await client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_commands(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def command_handler(ack): + await ack() + + app.command("/hello-world")(command_handler) + + input = ( + "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" + ) + timestamp, body = str(int(time())), input + + api = Quart(__name__) + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(): + return await app_handler.handle(request) + + client = api.test_client() + response = await client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_oauth(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + api = Quart(__name__) + app_handler = AsyncSlackRequestHandler(app) + + @api.get("/slack/install") + async def install(): + return await app_handler.handle(request) + + client = api.test_client() + response = await client.get("/slack/install") + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert response.headers.get("set-cookie") is not None + assert "https://slack.com/oauth/v2/authorize?state=" in await response.get_data(as_text=True) + + @pytest.mark.asyncio + async def test_oauth_callback(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + state_store=TestAsyncStateStore(), + ), + ) + api = Quart(__name__) + app_handler = AsyncSlackRequestHandler(app) + + @api.get("/slack/oauth_redirect") + async def oauth_redirect(): + return await app_handler.handle(request) + + client = api.test_client() + response = await client.get( + "/slack/oauth_redirect?code=1234567890&state=uuid4-value", + headers={"Cookie": "slack-app-oauth-state=uuid4-value"}, + ) + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert await response.get_data(as_text=True) is not None + + @pytest.mark.asyncio + async def test_url_verification(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + input = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", + } + + timestamp, body = str(int(time())), json.dumps(input) + + api = Quart(__name__) + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(): + return await app_handler.handle(request) + + client = api.test_client() + response = await client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert response.headers.get("content-type") == "application/json;charset=utf-8" + assert json.loads(await response.get_data(as_text=True)) == {"challenge": input["challenge"]} + assert_auth_test_count(self, 0) + + @pytest.mark.asyncio + async def test_not_found(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + api = Quart(__name__) + app_handler = AsyncSlackRequestHandler(app) + + @api.get("/slack/unknown") + async def unknown(): + return await app_handler.handle(request) + + client = api.test_client() + response = await client.get("/slack/unknown") + assert response.status_code == 404 + assert "Not Found" == await response.get_data(as_text=True) From 3c4800fa7e791f665c3f273a381b94e1d43886c0 Mon Sep 17 00:00:00 2001 From: Voronin Sergei Date: Sun, 31 May 2026 23:40:29 +1200 Subject: [PATCH 2/3] Simplify Quart adapter tests --- slack_bolt/adapter/quart/async_handler.py | 26 +-- tests/adapter_tests_async/test_async_quart.py | 193 ++++++------------ 2 files changed, 69 insertions(+), 150 deletions(-) diff --git a/slack_bolt/adapter/quart/async_handler.py b/slack_bolt/adapter/quart/async_handler.py index 5bdf7d7c4..c4edcf791 100644 --- a/slack_bolt/adapter/quart/async_handler.py +++ b/slack_bolt/adapter/quart/async_handler.py @@ -55,22 +55,18 @@ def __init__(self, app: AsyncApp): self.app = app async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response: - if req.method == "GET": - if self.app.oauth_flow is not None: - oauth_flow: AsyncOAuthFlow = self.app.oauth_flow - if req.path == oauth_flow.install_path: - bolt_resp = await oauth_flow.handle_installation( - await to_async_bolt_request(req, addition_context_properties) - ) - return await to_quart_response(bolt_resp) - elif req.path == oauth_flow.redirect_uri_path: - bolt_resp = await oauth_flow.handle_callback( - await to_async_bolt_request(req, addition_context_properties) - ) - return await to_quart_response(bolt_resp) - - elif req.method == "POST": + if req.method == "POST": bolt_resp = await self.app.async_dispatch(await to_async_bolt_request(req, addition_context_properties)) return await to_quart_response(bolt_resp) + if req.method == "GET" and self.app.oauth_flow is not None: + oauth_flow: AsyncOAuthFlow = self.app.oauth_flow + bolt_req = await to_async_bolt_request(req, addition_context_properties) + if req.path == oauth_flow.install_path: + bolt_resp = await oauth_flow.handle_installation(bolt_req) + return await to_quart_response(bolt_resp) + if req.path == oauth_flow.redirect_uri_path: + bolt_resp = await oauth_flow.handle_callback(bolt_req) + return await to_quart_response(bolt_resp) + return cast(Response, await make_response("Not Found", 404)) diff --git a/tests/adapter_tests_async/test_async_quart.py b/tests/adapter_tests_async/test_async_quart.py index 53dd91a22..b99625191 100644 --- a/tests/adapter_tests_async/test_async_quart.py +++ b/tests/adapter_tests_async/test_async_quart.py @@ -64,48 +64,58 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": timestamp, } - @pytest.mark.asyncio - async def test_events(self): - app = AsyncApp( + def build_app(self, oauth_settings=None): + return AsyncApp( client=self.web_client, signing_secret=self.signing_secret, + oauth_settings=oauth_settings, ) - async def event_handler(): - pass - - app.event("app_mention")(event_handler) - - input = { - "token": "verification_token", - "team_id": "T111", - "enterprise_id": "E111", - "api_app_id": "A111", - "event": { - "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", - "type": "app_mention", - "text": "<@W111> Hi there!", - "user": "W222", - "ts": "1595926230.009600", - "team": "T111", - "channel": "C111", - "event_ts": "1595926230.009600", - }, - "type": "event_callback", - "event_id": "Ev111", - "event_time": 1595926230, - "authed_users": ["W111"], - } - timestamp, body = str(int(time())), json.dumps(input) - + def build_client(self, app, path: str = "/slack/events", method: str = "POST", addition_context_properties=None): api = Quart(__name__) app_handler = AsyncSlackRequestHandler(app) - @api.post("/slack/events") async def endpoint(): - return await app_handler.handle(request) + return await app_handler.handle(request, addition_context_properties) + + api.add_url_rule(path, "endpoint", endpoint, methods=[method]) + return api.test_client() + + def build_event_body(self) -> str: + return json.dumps( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + ) + + @pytest.mark.asyncio + async def test_events(self): + app = self.build_app() - client = api.test_client() + async def event_handler(): + pass + + app.event("app_mention")(event_handler) + + timestamp, body = str(int(time())), self.build_event_body() + client = self.build_client(app) response = await client.post( "/slack/events", data=body, @@ -116,10 +126,7 @@ async def endpoint(): @pytest.mark.asyncio async def test_events_with_additional_context_properties(self): - app = AsyncApp( - client=self.web_client, - signing_secret=self.signing_secret, - ) + app = self.build_app() observed_context = {} async def event_handler(context): @@ -127,36 +134,8 @@ async def event_handler(context): app.event("app_mention")(event_handler) - input = { - "token": "verification_token", - "team_id": "T111", - "enterprise_id": "E111", - "api_app_id": "A111", - "event": { - "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", - "type": "app_mention", - "text": "<@W111> Hi there!", - "user": "W222", - "ts": "1595926230.009600", - "team": "T111", - "channel": "C111", - "event_ts": "1595926230.009600", - }, - "type": "event_callback", - "event_id": "Ev111", - "event_time": 1595926230, - "authed_users": ["W111"], - } - timestamp, body = str(int(time())), json.dumps(input) - - api = Quart(__name__) - app_handler = AsyncSlackRequestHandler(app) - - @api.post("/slack/events") - async def endpoint(): - return await app_handler.handle(request, {"custom_value": "quart"}) - - client = api.test_client() + timestamp, body = str(int(time())), self.build_event_body() + client = self.build_client(app, addition_context_properties={"custom_value": "quart"}) response = await client.post( "/slack/events", data=body, @@ -168,10 +147,7 @@ async def endpoint(): @pytest.mark.asyncio async def test_shortcuts(self): - app = AsyncApp( - client=self.web_client, - signing_secret=self.signing_secret, - ) + app = self.build_app() async def shortcut_handler(ack): await ack() @@ -195,14 +171,7 @@ async def shortcut_handler(ack): timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" - api = Quart(__name__) - app_handler = AsyncSlackRequestHandler(app) - - @api.post("/slack/events") - async def endpoint(): - return await app_handler.handle(request) - - client = api.test_client() + client = self.build_client(app) response = await client.post( "/slack/events", data=body, @@ -213,10 +182,7 @@ async def endpoint(): @pytest.mark.asyncio async def test_commands(self): - app = AsyncApp( - client=self.web_client, - signing_secret=self.signing_secret, - ) + app = self.build_app() async def command_handler(ack): await ack() @@ -240,14 +206,7 @@ async def command_handler(ack): ) timestamp, body = str(int(time())), input - api = Quart(__name__) - app_handler = AsyncSlackRequestHandler(app) - - @api.post("/slack/events") - async def endpoint(): - return await app_handler.handle(request) - - client = api.test_client() + client = self.build_client(app) response = await client.post( "/slack/events", data=body, @@ -258,23 +217,15 @@ async def endpoint(): @pytest.mark.asyncio async def test_oauth(self): - app = AsyncApp( - client=self.web_client, - signing_secret=self.signing_secret, + app = self.build_app( oauth_settings=AsyncOAuthSettings( client_id="111.111", client_secret="xxx", scopes=["chat:write", "commands"], ), ) - api = Quart(__name__) - app_handler = AsyncSlackRequestHandler(app) - - @api.get("/slack/install") - async def install(): - return await app_handler.handle(request) - client = api.test_client() + client = self.build_client(app, path="/slack/install", method="GET") response = await client.get("/slack/install") assert response.status_code == 200 assert response.headers.get("content-type") == "text/html; charset=utf-8" @@ -283,9 +234,7 @@ async def install(): @pytest.mark.asyncio async def test_oauth_callback(self): - app = AsyncApp( - client=self.web_client, - signing_secret=self.signing_secret, + app = self.build_app( oauth_settings=AsyncOAuthSettings( client_id="111.111", client_secret="xxx", @@ -293,14 +242,8 @@ async def test_oauth_callback(self): state_store=TestAsyncStateStore(), ), ) - api = Quart(__name__) - app_handler = AsyncSlackRequestHandler(app) - - @api.get("/slack/oauth_redirect") - async def oauth_redirect(): - return await app_handler.handle(request) - client = api.test_client() + client = self.build_client(app, path="/slack/oauth_redirect", method="GET") response = await client.get( "/slack/oauth_redirect?code=1234567890&state=uuid4-value", headers={"Cookie": "slack-app-oauth-state=uuid4-value"}, @@ -311,10 +254,7 @@ async def oauth_redirect(): @pytest.mark.asyncio async def test_url_verification(self): - app = AsyncApp( - client=self.web_client, - signing_secret=self.signing_secret, - ) + app = self.build_app() input = { "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", @@ -324,14 +264,7 @@ async def test_url_verification(self): timestamp, body = str(int(time())), json.dumps(input) - api = Quart(__name__) - app_handler = AsyncSlackRequestHandler(app) - - @api.post("/slack/events") - async def endpoint(): - return await app_handler.handle(request) - - client = api.test_client() + client = self.build_client(app) response = await client.post( "/slack/events", data=body, @@ -344,18 +277,8 @@ async def endpoint(): @pytest.mark.asyncio async def test_not_found(self): - app = AsyncApp( - client=self.web_client, - signing_secret=self.signing_secret, - ) - api = Quart(__name__) - app_handler = AsyncSlackRequestHandler(app) - - @api.get("/slack/unknown") - async def unknown(): - return await app_handler.handle(request) - - client = api.test_client() + app = self.build_app() + client = self.build_client(app, path="/slack/unknown", method="GET") response = await client.get("/slack/unknown") assert response.status_code == 404 assert "Not Found" == await response.get_data(as_text=True) From 47207055eeb91cd9ae029eb2308f1ed4cb2ac5d1 Mon Sep 17 00:00:00 2001 From: Voronin Sergei Date: Mon, 1 Jun 2026 00:10:30 +1200 Subject: [PATCH 3/3] Tighten Quart response handling --- examples/quart/async_app.py | 1 + examples/quart/async_oauth_app.py | 1 + examples/quart/requirements.txt | 1 + slack_bolt/adapter/quart/async_handler.py | 9 +-- tests/adapter_tests_async/test_async_quart.py | 61 ++++++++++++++++++- 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/examples/quart/async_app.py b/examples/quart/async_app.py index ef6f035f5..3bdf065ea 100644 --- a/examples/quart/async_app.py +++ b/examples/quart/async_app.py @@ -26,6 +26,7 @@ async def endpoint(): api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) +# Requires Python 3.9+ # pip install -r requirements.txt # export SLACK_SIGNING_SECRET=*** # export SLACK_BOT_TOKEN=xoxb-*** diff --git a/examples/quart/async_oauth_app.py b/examples/quart/async_oauth_app.py index b174b4671..86a35658b 100644 --- a/examples/quart/async_oauth_app.py +++ b/examples/quart/async_oauth_app.py @@ -36,6 +36,7 @@ async def oauth_redirect(): api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) +# Requires Python 3.9+ # pip install -r requirements.txt # # -- OAuth flow -- # diff --git a/examples/quart/requirements.txt b/examples/quart/requirements.txt index 39023e58d..8c610a67c 100644 --- a/examples/quart/requirements.txt +++ b/examples/quart/requirements.txt @@ -1 +1,2 @@ +# Requires Python 3.9+ quart>=0.20,<1 diff --git a/slack_bolt/adapter/quart/async_handler.py b/slack_bolt/adapter/quart/async_handler.py index c4edcf791..ee08499e7 100644 --- a/slack_bolt/adapter/quart/async_handler.py +++ b/slack_bolt/adapter/quart/async_handler.py @@ -36,13 +36,14 @@ async def to_quart_response(bolt_resp: BoltResponse) -> Response: for cookie in bolt_resp.cookies(): for name, c in cookie.items(): + max_age = int(c["max-age"]) if c.get("max-age") else None resp.set_cookie( key=name, value=c.value, - max_age=c.get("max-age"), - expires=c.get("expires"), - path=c.get("path"), - domain=c.get("domain"), + max_age=max_age, + expires=c.get("expires") or None, + path=c.get("path") or None, + domain=c.get("domain") or None, secure=True, httponly=True, ) diff --git a/tests/adapter_tests_async/test_async_quart.py b/tests/adapter_tests_async/test_async_quart.py index b99625191..c6d15be2d 100644 --- a/tests/adapter_tests_async/test_async_quart.py +++ b/tests/adapter_tests_async/test_async_quart.py @@ -8,6 +8,7 @@ from slack_sdk.signature import SignatureVerifier from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt import BoltResponse from slack_bolt.app.async_app import AsyncApp from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings from tests.mock_web_api_server import ( @@ -20,7 +21,7 @@ pytestmark = pytest.mark.skipif(sys.version_info < (3, 9), reason="Quart requires Python 3.9+") if sys.version_info >= (3, 9): - from slack_bolt.adapter.quart.async_handler import AsyncSlackRequestHandler + from slack_bolt.adapter.quart.async_handler import AsyncSlackRequestHandler, to_quart_response from quart import Quart, request @@ -275,6 +276,64 @@ async def test_url_verification(self): assert json.loads(await response.get_data(as_text=True)) == {"challenge": input["challenge"]} assert_auth_test_count(self, 0) + @pytest.mark.asyncio + async def test_to_quart_response_preserves_multi_value_headers_and_content_type(self): + api = Quart(__name__) + async with api.app_context(): + response = await to_quart_response( + BoltResponse( + status=201, + body="created", + headers={ + "content-type": "application/custom", + "x-bolt-test": ["one", "two"], + }, + ) + ) + + assert response.status_code == 201 + assert await response.get_data(as_text=True) == "created" + assert response.headers.get("content-type") == "application/custom" + assert response.headers.getlist("x-bolt-test") == ["one", "two"] + + @pytest.mark.asyncio + async def test_to_quart_response_preserves_cookie_attributes(self): + api = Quart(__name__) + async with api.app_context(): + response = await to_quart_response( + BoltResponse( + status=200, + body="", + headers={ + "set-cookie": [ + "session=abc; Max-Age=60; Path=/install; Domain=example.com", + "bare=xyz", + ], + }, + ) + ) + + set_cookie_headers = response.headers.getlist("set-cookie") + assert len(set_cookie_headers) == 2 + + session_cookie = set_cookie_headers[0] + assert "session=abc" in session_cookie + assert "Domain=example.com" in session_cookie + assert "Max-Age=60" in session_cookie + assert "Path=/install" in session_cookie + assert "Secure" in session_cookie + assert "HttpOnly" in session_cookie + assert "Expires=;" not in session_cookie + + bare_cookie = set_cookie_headers[1] + assert "bare=xyz" in bare_cookie + assert "Secure" in bare_cookie + assert "HttpOnly" in bare_cookie + assert "Domain=" not in bare_cookie + assert "Expires=" not in bare_cookie + assert "Max-Age=" not in bare_cookie + assert "Path=" not in bare_cookie + @pytest.mark.asyncio async def test_not_found(self): app = self.build_app()