diff --git a/examples/google_cloud_functions/.env.yaml.oauth-sample b/examples/google_cloud_functions/.env.yaml.oauth-sample new file mode 100644 index 000000000..75ff16f8b --- /dev/null +++ b/examples/google_cloud_functions/.env.yaml.oauth-sample @@ -0,0 +1,4 @@ +SLACK_CLIENT_ID: '1111.222' +SLACK_CLIENT_SECRET: 'xxx' +SLACK_SIGNING_SECRET: 'yyy' +SLACK_SCOPES: 'app_mentions:read,chat:write,commands' diff --git a/examples/google_cloud_functions/.gitignore b/examples/google_cloud_functions/.gitignore index 69748e961..312fe55e1 100644 --- a/examples/google_cloud_functions/.gitignore +++ b/examples/google_cloud_functions/.gitignore @@ -1 +1,2 @@ -.env.yaml \ No newline at end of file +.env.yaml +main.py diff --git a/examples/google_cloud_functions/datastore.py b/examples/google_cloud_functions/datastore.py new file mode 100644 index 000000000..dfdfbcbc8 --- /dev/null +++ b/examples/google_cloud_functions/datastore.py @@ -0,0 +1,252 @@ +# +# Please note that this is an example implementation. +# You can reuse this implementation for your app, +# but we don't have short-term plans to add this code to slack-sdk package. +# Please maintain the code on your own if you copy this file. +# +# Also, please refer to the following gist for more discussion and better implementation: +# https://gist.github.com/seratch/d81a445ef4467b16f047156bf859cda8 +# + +import logging +from logging import Logger +from typing import Optional +from uuid import uuid4 + +from google.cloud import datastore +from google.cloud.datastore import Client, Entity, Query +from slack_sdk.oauth import OAuthStateStore, InstallationStore +from slack_sdk.oauth.installation_store import Installation, Bot + + +class GoogleDatastoreInstallationStore(InstallationStore): + datastore_client: Client + + def __init__( + self, + *, + datastore_client: Client, + logger: Logger, + ): + self.datastore_client = datastore_client + self._logger = logger + + @property + def logger(self) -> Logger: + if self._logger is None: + self._logger = logging.getLogger(__name__) + return self._logger + + def installation_key( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str], + suffix: Optional[str] = None, + is_enterprise_install: Optional[bool] = None, + ): + enterprise_id = enterprise_id or "none" + team_id = "none" if is_enterprise_install else team_id or "none" + name = ( + f"{enterprise_id}-{team_id}-{user_id}" + if user_id + else f"{enterprise_id}-{team_id}" + ) + if suffix is not None: + name += "-" + suffix + return self.datastore_client.key("installations", name) + + def bot_key( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + suffix: Optional[str] = None, + is_enterprise_install: Optional[bool] = None, + ): + enterprise_id = enterprise_id or "none" + team_id = "none" if is_enterprise_install else team_id or "none" + name = f"{enterprise_id}-{team_id}" + if suffix is not None: + name += "-" + suffix + return self.datastore_client.key("bots", name) + + def save(self, i: Installation): + # the latest installation in the workspace + installation_entity: Entity = datastore.Entity( + key=self.installation_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + user_id=None, # user_id is removed + is_enterprise_install=i.is_enterprise_install, + ) + ) + installation_entity.update(**i.to_dict()) + self.datastore_client.put(installation_entity) + + # the latest installation associated with a user + user_entity: Entity = datastore.Entity( + key=self.installation_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + user_id=i.user_id, + is_enterprise_install=i.is_enterprise_install, + ) + ) + user_entity.update(**i.to_dict()) + self.datastore_client.put(user_entity) + # history data + user_entity.key = self.installation_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + user_id=i.user_id, + is_enterprise_install=i.is_enterprise_install, + suffix=str(i.installed_at), + ) + self.datastore_client.put(user_entity) + + # the latest bot authorization in the workspace + bot = i.to_bot() + bot_entity: Entity = datastore.Entity( + key=self.bot_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + is_enterprise_install=i.is_enterprise_install, + ) + ) + bot_entity.update(**bot.to_dict()) + self.datastore_client.put(bot_entity) + # history data + bot_entity.key = self.bot_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + is_enterprise_install=i.is_enterprise_install, + suffix=str(i.installed_at), + ) + self.datastore_client.put(bot_entity) + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + entity: Entity = self.datastore_client.get( + self.bot_key( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=is_enterprise_install, + ) + ) + if entity is not None: + entity["installed_at"] = entity["installed_at"].timestamp() + return Bot(**entity) + return None + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + entity: Entity = self.datastore_client.get( + self.installation_key( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=is_enterprise_install, + ) + ) + if entity is not None: + entity["installed_at"] = entity["installed_at"].timestamp() + return Installation(**entity) + return None + + def delete_installation( + self, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str], + ) -> None: + installation_key = self.installation_key( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + ) + q: Query = self.datastore_client.query() + q.key_filter(installation_key, ">=") + for entity in q.fetch(): + if entity.key.name.startswith(installation_key.name): + self.datastore_client.delete(entity.key) + else: + break + + def delete_bot( + self, + enterprise_id: Optional[str], + team_id: Optional[str], + ) -> None: + bot_key = self.bot_key( + enterprise_id=enterprise_id, + team_id=team_id, + ) + q: Query = self.datastore_client.query() + q.key_filter(bot_key, ">=") + for entity in q.fetch(): + if entity.key.name.startswith(bot_key.name): + self.datastore_client.delete(entity.key) + else: + break + + def delete_all( + self, + enterprise_id: Optional[str], + team_id: Optional[str], + ): + self.delete_bot(enterprise_id=enterprise_id, team_id=team_id) + self.delete_installation( + enterprise_id=enterprise_id, team_id=team_id, user_id=None + ) + + +class GoogleDatastoreOAuthStateStore(OAuthStateStore): + logger: Logger + datastore_client: Client + collection_id: str + + def __init__( + self, + *, + datastore_client: Client, + logger: Logger, + ): + self.datastore_client = datastore_client + self._logger = logger + self.collection_id = "oauth_state_values" + + @property + def logger(self) -> Logger: + if self._logger is None: + self._logger = logging.getLogger(__name__) + return self._logger + + def consume(self, state: str) -> bool: + key = self.datastore_client.key(self.collection_id, state) + entity = self.datastore_client.get(key) + if entity is not None: + self.datastore_client.delete(key) + return True + return False + + def issue(self, *args, **kwargs) -> str: + state_value = str(uuid4()) + entity: Entity = datastore.Entity( + key=self.datastore_client.key(self.collection_id, state_value) + ) + entity.update(value=state_value) + self.datastore_client.put(entity) + return state_value diff --git a/examples/google_cloud_functions/handler.py b/examples/google_cloud_functions/handler.py new file mode 100644 index 000000000..df5b9a351 --- /dev/null +++ b/examples/google_cloud_functions/handler.py @@ -0,0 +1,66 @@ +# TODO: Once this once a new version newer than 1.13.2, delete this file + +from typing import Callable + +from flask import Request, Response, make_response + +from slack_bolt.app import App +from slack_bolt.error import BoltError +from slack_bolt.lazy_listener import LazyListenerRunner +from slack_bolt.oauth import OAuthFlow +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +def to_bolt_request(req: Request) -> BoltRequest: + return BoltRequest( # type: ignore + body=req.get_data(as_text=True), + query=req.query_string.decode("utf-8"), + headers=req.headers, # type: ignore + ) # type: ignore + + +def to_flask_response(bolt_resp: BoltResponse) -> Response: + resp: Response = make_response(bolt_resp.body, bolt_resp.status) + for k, values in bolt_resp.headers.items(): + if k.lower() == "content-type" and resp.headers.get("content-type") is not None: + # Remove the one set by Flask + resp.headers.pop("content-type") + for v in values: + resp.headers.add_header(k, v) + return resp + + +class NoopLazyListenerRunner(LazyListenerRunner): + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + raise BoltError( + "The google_cloud_functions adapter does not support lazy listeners. " + "Please consider either having a queue to pass the request to a different function or " + "rewriting your code not to use lazy listeners." + ) + + +class SlackRequestHandler: + def __init__(self, app: App): # type: ignore + self.app = app + # Note that lazy listener is not supported + self.app.listener_runner.lazy_listener_runner = NoopLazyListenerRunner() + if self.app.oauth_flow is not None: + self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?" + + def handle(self, req: Request) -> Response: + if req.method == "GET": + if self.app.oauth_flow is not None: + oauth_flow: OAuthFlow = self.app.oauth_flow + bolt_req = to_bolt_request(req) + if "code" in req.args or "error" in req.args or "state" in req.args: + bolt_resp = oauth_flow.handle_callback(bolt_req) + return to_flask_response(bolt_resp) + else: + bolt_resp = oauth_flow.handle_installation(bolt_req) + return to_flask_response(bolt_resp) + elif req.method == "POST": + bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req)) + return to_flask_response(bolt_resp) + + return make_response("Not Found", 404) diff --git a/examples/google_cloud_functions/oauth_main.py b/examples/google_cloud_functions/oauth_main.py new file mode 100644 index 000000000..f6355f779 --- /dev/null +++ b/examples/google_cloud_functions/oauth_main.py @@ -0,0 +1,101 @@ +# https://cloud.google.com/functions/docs/first-python + +import logging + +from slack_bolt.oauth.oauth_settings import OAuthSettings + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App +from datastore import GoogleDatastoreInstallationStore, GoogleDatastoreOAuthStateStore + +from google.cloud import datastore + +datastore_client = datastore.Client() +logger = logging.getLogger(__name__) + +# process_before_response must be True when running on FaaS +app = App( + process_before_response=True, + installation_store=GoogleDatastoreInstallationStore( + datastore_client=datastore_client, + logger=logger, + ), + oauth_settings=OAuthSettings( + state_store=GoogleDatastoreOAuthStateStore( + datastore_client=datastore_client, + logger=logger, + ), + ), +) + + +@app.command("/hello-bolt-python-gcp") +def hello_command(ack): + ack("Hi from Google Cloud Functions!") + + +@app.event("app_mention") +def event_test(body, say, logger): + logger.info(body) + say("Hi from Google Cloud Functions!") + + +# Flask adapter +# TODO: Once this once a new version newer than 1.13.2, delete handler and enable the slack_bolt.adapter import instead +# from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler +from handler import SlackRequestHandler +from flask import Request + + +handler = SlackRequestHandler(app) + + +# Cloud Function +def hello_bolt_app(req: Request): + """HTTP Cloud Function. + Args: + req (flask.Request): The request object. + + Returns: + The response text, or any set of values that can be turned into a + Response object using `make_response` + . + """ + return handler.handle(req) + + +# For local development +# python main.py +if __name__ == "__main__": + from flask import Flask, request + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["GET", "POST"]) + def handle_anything(): + return handler.handle(request) + + flask_app.run(port=3000) + + +# Step1: Create a new Slack App: https://api.slack.com/apps +# Bot Token Scopes: app_mentions:read,chat:write,commands + +# Step2: Set env variables +# cp .env.yaml.oauth-sample .env.yaml +# vi .env.yaml + +# Step3: Create a new Google Cloud project +# gcloud projects create YOUR_PROJECT_NAME +# gcloud config set project YOUR_PROJECT_NAME + +# Step4: Deploy a function in the project +# cp oauth_main.py main.py +# gcloud functions deploy hello_bolt_app --runtime python38 --trigger-http --allow-unauthenticated --env-vars-file .env.yaml +# gcloud functions describe hello_bolt_app + +# Step5: Set Request URL +# Set https://us-central1-YOUR_PROJECT_NAME.cloudfunctions.net/hello_bolt_app to the following: +# * slash command: /hello-bolt-python-gcp +# * Events Subscriptions & add `app_mention` event diff --git a/examples/google_cloud_functions/requirements.txt b/examples/google_cloud_functions/requirements.txt index d682b1606..7ebbe8ab2 100644 --- a/examples/google_cloud_functions/requirements.txt +++ b/examples/google_cloud_functions/requirements.txt @@ -1,2 +1,3 @@ Flask>1 -slack_bolt \ No newline at end of file +slack_bolt +google-cloud-datastore>=2.1.0,<3 \ No newline at end of file diff --git a/examples/google_cloud_functions/main.py b/examples/google_cloud_functions/simple_main.py similarity index 64% rename from examples/google_cloud_functions/main.py rename to examples/google_cloud_functions/simple_main.py index c9f911a90..bc8f593a3 100644 --- a/examples/google_cloud_functions/main.py +++ b/examples/google_cloud_functions/simple_main.py @@ -22,27 +22,45 @@ def event_test(body, say, logger): # Flask adapter -from slack_bolt.adapter.flask import SlackRequestHandler +# TODO: Once this once a new version newer than 1.13.2, delete handler and enable the slack_bolt.adapter import instead +# from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler +from handler import SlackRequestHandler +from flask import Request + handler = SlackRequestHandler(app) # Cloud Function -def hello_bolt_app(request): +def hello_bolt_app(req: Request): """HTTP Cloud Function. Args: - request (flask.Request): The request object. + req (flask.Request): The request object. Returns: The response text, or any set of values that can be turned into a Response object using `make_response` . """ - return handler.handle(request) + return handler.handle(req) + + +# For local development +# python main.py +if __name__ == "__main__": + from flask import Flask, request + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["GET", "POST"]) + def handle_anything(): + return handler.handle(request) + + flask_app.run(port=3000) # Step1: Create a new Slack App: https://api.slack.com/apps -# Bot Token Scopes: chat:write, commands, app_mentions:read +# Bot Token Scopes: app_mentions:read,chat:write,commands # Step2: Set env variables # cp .env.yaml.sample .env.yaml @@ -53,7 +71,8 @@ def hello_bolt_app(request): # gcloud config set project YOUR_PROJECT_NAME # Step4: Deploy a function in the project -# gcloud functions deploy hello_bolt_app --runtime python38 --trigger-http --allow-unauthenticated --env-vars-file .env.yaml +# cp simple_main.py main.py +# gcloud functions deploy hello_bolt_app --runtime python39 --trigger-http --allow-unauthenticated --env-vars-file .env.yaml # gcloud functions describe hello_bolt_app # Step5: Set Request URL diff --git a/slack_bolt/adapter/google_cloud_functions/__init__.py b/slack_bolt/adapter/google_cloud_functions/__init__.py new file mode 100644 index 000000000..83f4882db --- /dev/null +++ b/slack_bolt/adapter/google_cloud_functions/__init__.py @@ -0,0 +1,5 @@ +from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/google_cloud_functions/handler.py b/slack_bolt/adapter/google_cloud_functions/handler.py new file mode 100644 index 000000000..46a9c0523 --- /dev/null +++ b/slack_bolt/adapter/google_cloud_functions/handler.py @@ -0,0 +1,43 @@ +from typing import Callable + +from flask import Request, Response, make_response + +from slack_bolt.adapter.flask.handler import to_bolt_request, to_flask_response +from slack_bolt.app import App +from slack_bolt.error import BoltError +from slack_bolt.lazy_listener import LazyListenerRunner +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +class NoopLazyListenerRunner(LazyListenerRunner): + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + raise BoltError( + "The google_cloud_functions adapter does not support lazy listeners. " + "Please consider either having a queue to pass the request to a different function or " + "rewriting your code not to use lazy listeners." + ) + + +class SlackRequestHandler: + def __init__(self, app: App): # type: ignore + self.app = app + # Note that lazy listener is not supported + self.app.listener_runner.lazy_listener_runner = NoopLazyListenerRunner() + if self.app.oauth_flow is not None: + self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?" + + def handle(self, req: Request) -> Response: + if req.method == "GET" and self.app.oauth_flow is not None: + bolt_req = to_bolt_request(req) + if "code" in req.args or "error" in req.args or "state" in req.args: + bolt_resp = self.app.oauth_flow.handle_callback(bolt_req) + return to_flask_response(bolt_resp) + else: + bolt_resp = self.app.oauth_flow.handle_installation(bolt_req) + return to_flask_response(bolt_resp) + elif req.method == "POST": + bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req)) + return to_flask_response(bolt_resp) + + return make_response("Not Found", 404) diff --git a/tests/adapter_tests/google_cloud_functions/__init__.py b/tests/adapter_tests/google_cloud_functions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/google_cloud_functions/test_google_cloud_functions.py b/tests/adapter_tests/google_cloud_functions/test_google_cloud_functions.py new file mode 100644 index 000000000..18420bdfe --- /dev/null +++ b/tests/adapter_tests/google_cloud_functions/test_google_cloud_functions.py @@ -0,0 +1,242 @@ +import json +from time import time +from urllib.parse import quote + +from flask import Flask, request +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestGoogleCloudFunctions: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + 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], + } + + def test_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + 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) + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/function", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack): + 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))}" + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/function", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_commands(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + 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 + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/function", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["GET"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.get("/function") + assert rv.headers.get("content-type") == "text/html; charset=utf-8" + assert rv.status_code == 200 + + def test_url_verification(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + input = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/function", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "application/json;charset=utf-8" + assert_auth_test_count(self, 1)