Skip to content

Commit

Permalink
feat: dependency to handle slash commands request (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
hadrien authored Dec 30, 2020
1 parent f8cc398 commit 1dac38c
Show file tree
Hide file tree
Showing 15 changed files with 352 additions and 22 deletions.
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@ from fastapi import FastAPI


app = FastAPI()

app.include_router(fastapi_slack.router)
```

## [Slash Commands]

* Dependency `fastapi_slack.with_slash_command` validates request signature and extract
the slash command info needed to process it:

```python
from fastapi import Depends, FastAPI
from fastapi_slack import SlashCommand, router, with_slash_command

app = FastAPI()
app.include_router(router)


@app.post("/slash-commands")
def process_commands(slash_command: SlashCommand = Depends(with_slash_command)):
pass
```


[Slash Commands]: https://api.slack.com/interactivity/slash-commands
86 changes: 83 additions & 3 deletions fastapi_slack.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
from fastapi import APIRouter
from hashlib import sha256
from hmac import HMAC
from time import time
from urllib.parse import parse_qsl

from fastapi import APIRouter, Depends, Header, HTTPException, Request
from pkg_resources import get_distribution
from pydantic import BaseSettings, SecretStr, ValidationError
from pydantic import BaseModel, BaseSettings, SecretStr, ValidationError

__all__ = ["router"]
__all__ = ["SlashCommand", "router", "with_slash_command"]
__version__ = get_distribution("fastapi-slack").version

router = APIRouter()


Expand All @@ -15,9 +21,83 @@ class Config:
env_prefix = "slack_"


class SlashCommand(BaseModel):
token: str
team_id: str
team_domain: str
enterprise_id: str
enterprise_name: str
channel_id: str
channel_name: str
user_id: str
user_name: str
command: str
text: str
response_url: str
trigger_id: str
api_app_id: str


@router.on_event("startup")
async def on_startup():
try:
Settings()
except ValidationError as error:
raise Exception("Missing environment variable(s)") from error


def with_valid_timestamp(x_slack_request_timestamp: int = Header(...)) -> int:
now = time()
more_than_thirty_seconds = now - x_slack_request_timestamp > 30
if more_than_thirty_seconds:
raise HTTPException(400, "Invalid timestamp")

return x_slack_request_timestamp


async def with_body(request: Request) -> bytes:
"""Return request body.
Per design, a route cannot depend on a form field and consume body because
dependencies resolution consumes body.
Therefore, this is not using `fastapi.Form` to extract form data parameters.
"""
return await request.body()


def with_form_data(body: bytes = Depends(with_body)) -> dict:
return dict(parse_qsl(body.decode()))


def check_signature(secret: str, timestamp: int, signature: str, body: bytes) -> bool:
"""Return True if signature is valid and False if not."""
signature_message = f"v0:{timestamp}:{body.decode()}"
local_hash = HMAC(secret.encode(), signature_message.encode(), sha256).hexdigest()
local_signature = f"v0={local_hash}"
return local_signature == signature


def with_settings() -> Settings:
try:
return Settings()
except ValidationError as error:
raise HTTPException(500) from error


def with_valid_signature(
body: bytes = Depends(with_body),
settings: Settings = Depends(with_settings),
timestamp: int = Depends(with_valid_timestamp),
signature: str = Header(..., alias="X-Slack-Signature"),
) -> str:
secret = settings.signing_secret.get_secret_value()
if check_signature(secret, timestamp, signature, body) is False:
raise HTTPException(403, "invalid signature")
return signature


def with_slash_command(
form_data: dict = Depends(with_form_data),
signature=Depends(with_valid_signature),
) -> SlashCommand:
return SlashCommand(**form_data)
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,10 @@ def environ():
}
with patch.dict("os.environ", environ, clear=True):
yield environ


@fixture
def settings(environ):
from fastapi_slack import Settings

return Settings()
24 changes: 24 additions & 0 deletions tests/functionals/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from pytest import fixture


@fixture
async def app():
from demo import app

from fastapi_slack import with_valid_signature

async with LifespanManager(app):
app.dependency_overrides[with_valid_signature] = lambda: "signature"
yield app


@fixture
async def client(app):
async with LifespanManager(app):
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://example.local"
) as client:
yield client
11 changes: 11 additions & 0 deletions tests/functionals/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from fastapi import Depends, FastAPI

from fastapi_slack import SlashCommand, router, with_slash_command

app = FastAPI(title="demo")
app.include_router(router)


@app.post("/slack-commands")
def commands(command: SlashCommand = Depends(with_slash_command)):
pass
28 changes: 28 additions & 0 deletions tests/functionals/test_slash_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from pytest import fixture, mark

pytestmark = mark.asyncio


@fixture
def payload() -> str:
return (
"token=gIkuvaNzQIHg97ATvDxqgjtO"
"&team_id=T0001"
"&team_domain=example"
"&enterprise_id=E0001"
"&enterprise_name=Globular%20Construct%20Inc"
"&channel_id=C2147483705"
"&channel_name=test"
"&user_id=U2147483697"
"&user_name=Steve"
"&command=/weather"
"&text=94070"
"&response_url=https://hooks.slack.com/commands/1234/5678"
"&trigger_id=13345224609.738474920.8088930838d88f008e0"
"&api_app_id=A123456"
)


async def test_slack_command(client, payload):
res = await client.post("/slack-commands", content=payload)
assert res.status_code == 200, res.content
18 changes: 0 additions & 18 deletions tests/test_settings.py

This file was deleted.

43 changes: 43 additions & 0 deletions tests/unittests/test_check_signature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from pytest import fixture


@fixture
def secret():
"""Valid slack signing secret."""
return "e33f1fb286333652295f920c461de67d"


@fixture
def timestamp():
"""Valid timestamp."""
return 1602970682


@fixture
def signature():
"""Valid signature."""
return "v0=9f214e30377799cf44e680e22991f04147a32bf2434954247728d60304180dc2"


@fixture
def body() -> bytes:
"""Valid slack command body."""
return (
b"token=zLKyq9QNyqgGQ3weXwtSov90&team_id=T129F61FC&team_domain=dialoguemd&channe"
b"l_id=D1V7CLUH3&channel_name=directmessage&user_id=U1V5RS49Z&user_name=hadrien&"
b"command=%2Fdev-ohs&text=&api_app_id=A01D64BPMTK&response_url=https%3A%2F%2Fhoo"
b"ks.slack.com%2Fcommands%2FT129F61FC%2F1435644863042%2FgV9qwUtCPGS3AQry7kNLiVsX"
b"&trigger_id=1428914092678.36321205522.18053fc39d1f89c8bab43fd18ee47ed8"
)


def test_check_signature(secret, timestamp, signature, body):
from fastapi_slack import check_signature

assert check_signature(secret, timestamp, signature, body) is True


def test_check_signature_return_false_on_invalid_signature():
from fastapi_slack import check_signature

assert check_signature("secret", 12345, "signature", b"body") is False
38 changes: 38 additions & 0 deletions tests/unittests/test_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from fastapi import HTTPException
from pytest import fixture, mark, raises

pytestmark = mark.asyncio


@fixture
def missing_variables(monkeypatch):
monkeypatch.delenv("slack_access_token")
monkeypatch.delenv("slack_signing_secret")


async def test_on_startup_fails_with_invalid_settings(missing_variables):
from fastapi_slack import on_startup

with raises(Exception):
await on_startup()


async def test_on_startup():
from fastapi_slack import on_startup

await on_startup()


async def test_with_settings(settings):
from fastapi_slack import with_settings

assert with_settings() == settings


async def test_with_settings_raises_500_with_missing_variables(missing_variables):
from fastapi_slack import with_settings

with raises(HTTPException) as raise_info:
with_settings()

assert raise_info.value.status_code == 500
File renamed without changes.
15 changes: 15 additions & 0 deletions tests/unittests/test_with_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from unittest.mock import AsyncMock

from fastapi import Request
from pytest import mark

pytestmark = mark.asyncio


async def test_with_body():
from fastapi_slack import with_body

request = AsyncMock(spec=Request)
request.body.return_value = b"b o d y"

assert await with_body(request) == b"b o d y"
7 changes: 7 additions & 0 deletions tests/unittests/test_with_form_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
def test_with_form_data():
from fastapi_slack import with_form_data

assert with_form_data(b"param1=1&param2=abc") == {
"param1": "1",
"param2": "abc",
}
30 changes: 30 additions & 0 deletions tests/unittests/test_with_slash_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from pytest import fixture


@fixture
def form_data() -> dict:
# https://api.slack.com/interactivity/slash-commands#app_command_handling
return {
"token": "gIkuvaNzQIHg97ATvDxqgjtO",
"team_id": "T0001",
"team_domain": "example",
"enterprise_id": "E0001",
"enterprise_name": "Globular Construct Inc",
"channel_id": "C2147483705",
"channel_name": "test",
"user_id": "U2147483697",
"user_name": "Steve",
"command": "/weather",
"text": "94070",
"response_url": "https://hooks.slack.com/commands/1234/5678",
"trigger_id": "13345224609.738474920.8088930838d88f008e0",
"api_app_id": "A123456",
}


def test_slash_commands(settings, form_data):
from fastapi_slack import with_slash_command

slash_command = with_slash_command(form_data, "signature")

assert slash_command.dict() == form_data
29 changes: 29 additions & 0 deletions tests/unittests/test_with_valid_signature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from unittest.mock import patch

from fastapi import HTTPException
from pytest import fixture, raises


@fixture
def check_signature():
with patch("fastapi_slack.check_signature") as check_signature:
check_signature.return_value = True
yield check_signature


def test_with_valid_signature(check_signature, settings):
from fastapi_slack import with_valid_signature

with_valid_signature(b"b o d y", settings, 12345, "signature")


def test_with_valid_signature_raises_403_when_signature_is_invalid(
check_signature, settings
):
from fastapi_slack import with_valid_signature

check_signature.return_value = False
with raises(HTTPException) as raise_info:
with_valid_signature(b"b o d y", settings, 12345, "signature")

assert raise_info.value.status_code == 403
Loading

0 comments on commit 1dac38c

Please sign in to comment.