Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add WSGI adapter #1085

Merged
merged 9 commits into from
May 28, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
19 changes: 19 additions & 0 deletions examples/wsgi/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from slack_bolt import App
from slack_bolt.adapter.wsgi import SlackRequestHandler

app = App()


@app.event("app_mention")
def handle_app_mentions(body, say, logger):
logger.info(body)
say("What's up?")


api = SlackRequestHandler(app)

# pip install -r requirements.txt
# export SLACK_SIGNING_SECRET=***
# export SLACK_BOT_TOKEN=xoxb-***
# gunicorn app:api -b 0.0.0.0:3000 --log-level debug
# ngrok http 3000
23 changes: 23 additions & 0 deletions examples/wsgi/oauth_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from slack_bolt import App
from slack_bolt.adapter.wsgi import SlackRequestHandler

app = App()


@app.event("app_mention")
def handle_app_mentions(body, say, logger):
logger.info(body)
say("What's up?")


api = SlackRequestHandler(app)

# 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

# gunicorn oauth_app:api -b 0.0.0.0:3000 --log-level debug
1 change: 1 addition & 0 deletions examples/wsgi/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
gunicorn<23
3 changes: 3 additions & 0 deletions slack_bolt/adapter/wsgi/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .handler import SlackRequestHandler

__all__ = ["SlackRequestHandler"]
87 changes: 87 additions & 0 deletions slack_bolt/adapter/wsgi/handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from typing import Any, Callable, Dict, Iterable, List, Tuple
from slack_bolt.oauth.oauth_flow import OAuthFlow
from slack_bolt.adapter.wsgi.http_request import WsgiHttpRequest
from slack_bolt.adapter.wsgi.http_response import WsgiHttpResponse

from slack_bolt import App

from slack_bolt.request import BoltRequest
from slack_bolt.response import BoltResponse


class SlackRequestHandler:
def __init__(self, app: App, path: str = "/slack/events"):
"""Setup Bolt as a WSGI web framework, this will make your application compatible with WSGI web servers.
This can be used for production deployments.

With the default settings, `http://localhost:3000/slack/events`
Run Bolt with [gunicorn](https://gunicorn.org/)

# Python
app = App()

api = SlackRequestHandler(app)

# bash
export SLACK_SIGNING_SECRET=***

export SLACK_BOT_TOKEN=xoxb-***

gunicorn app:api -b 0.0.0.0:3000 --log-level debug

Args:
app: Your bolt application
path: The path to handle request from Slack (Default: `/slack/events`)
"""
self.path = path
self.app = app

def dispatch(self, request: WsgiHttpRequest) -> BoltResponse:
return self.app.dispatch(
BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
)

def handle_installation(self, request: WsgiHttpRequest) -> BoltResponse:
oauth_flow: OAuthFlow = self.app.oauth_flow
return oauth_flow.handle_installation(
BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
)

def handle_callback(self, request: WsgiHttpRequest) -> BoltResponse:
oauth_flow: OAuthFlow = self.app.oauth_flow
return oauth_flow.handle_callback(
BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
)

def _get_http_response(self, method: str, path: str, request: WsgiHttpRequest) -> WsgiHttpResponse:
if method == "GET":
if self.app.oauth_flow is not None:
if path == self.app.oauth_flow.install_path:
bolt_response: BoltResponse = self.handle_installation(request)
return WsgiHttpResponse(
status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body
)
if path == self.app.oauth_flow.redirect_uri_path:
bolt_response: BoltResponse = self.handle_callback(request)
return WsgiHttpResponse(
status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body
)
if method == "POST" and path == self.path:
bolt_response: BoltResponse = self.dispatch(request)
return WsgiHttpResponse(status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body)
return WsgiHttpResponse(status=404, headers={"content-type": ["text/plain;charset=utf-8"]}, body="Not Found")

def __call__(
self,
environ: Dict[str, Any],
start_response: Callable[[str, List[Tuple[str, str]]], None],
) -> Iterable[bytes]:
if "HTTP" in environ.get("SERVER_PROTOCOL", ""):
response: WsgiHttpResponse = self._get_http_response(
method=environ.get("REQUEST_METHOD", "GET"),
path=environ.get("PATH_INFO", ""),
request=WsgiHttpRequest(environ),
)
start_response(response.status, response.headers)
return response.body
raise TypeError(f"Unsupported SERVER_PROTOCOL: {environ['SERVER_PROTOCOL']}")
28 changes: 28 additions & 0 deletions slack_bolt/adapter/wsgi/http_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from typing import Any, Dict

from .utils import ENCODING


class WsgiHttpRequest:
__slots__ = ("query_string", "environ")

def __init__(self, environ: Dict[str, Any]):
self.query_string = environ.get("QUERY_STRING", "")
self.environ = environ

def get_headers(self) -> Dict[str, str]:
headers = {}
for key, value in self.environ.items():
if key in {"CONTENT_LENGTH", "CONTENT_TYPE"}:
name = key.lower().replace("_", "-")
headers[name] = value
if key.startswith("HTTP_"):
name = key[len("HTTP_") :].lower().replace("_", "-")
headers[name] = value
return headers

def get_body(self) -> str:
if "wsgi.input" not in self.environ:
return ""
content_length = int(self.environ.get("CONTENT_LENGTH", 0))
return self.environ["wsgi.input"].read(content_length).decode(ENCODING)
22 changes: 22 additions & 0 deletions slack_bolt/adapter/wsgi/http_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from typing import Sequence, Tuple, Dict, List
from http import HTTPStatus

from .utils import ENCODING


class WsgiHttpResponse:
__slots__ = ("status", "headers", "body")

def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""):
_status = HTTPStatus(status)
self.status = f"{_status.value} {_status.phrase}"

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

_body = bytes(body, ENCODING)
self.headers.append(("content-length", str(len(_body))))
self.body = [_body]
1 change: 1 addition & 0 deletions slack_bolt/adapter/wsgi/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ENCODING = "utf-8" # should always be utf-8
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine for Slack apps but perhaps the comment should be clearer to clarify we understand this is not true for WSGI in general:

Suggested change
ENCODING = "utf-8" # should always be utf-8
ENCODING = "utf-8" # The content encoding for Slack requests/responses is always utf-8

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it's good to rename this file to "intenrals.py" for consistency with other source files in this project?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed the file to internals.py and updated the comment 💯

Empty file.
Loading
Loading