Skip to content

Commit 7e87423

Browse files
authored
FastAPI WebhookRouter and codecov (#2)
1 parent 0b9f661 commit 7e87423

10 files changed

Lines changed: 361 additions & 33 deletions

File tree

.github/workflows/main.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,7 @@ jobs:
3939
- name: Lint - flake8
4040
run: tox -e flake8
4141
if: ${{ matrix.python-version == '3.8' }}
42+
- name: Upload Coverage to Codecov
43+
uses: codecov/codecov-action@v2
44+
# Only upload coverage for Python 3.8
45+
if: ${{ matrix.python-version == '3.8' }}

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Added
9+
- `WebhookRouter` for `FastAPI` users to extend `APIRouter` with built-in webhook signature validation
10+
- Upload coverage report to [Codecov](https://codecov.io/)
11+
812
## [0.2.0] - 2021-12-29
913
### Added
1014
- `WebhookAuth` injects signed request body as a header for `httpx`

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ A set of utilities for interacting with webhooks.
55
[![Test Webhook Utils](https://github.com/tizz98/webhook-utils/actions/workflows/main.yaml/badge.svg?branch=main)](https://github.com/tizz98/webhook-utils/actions/workflows/main.yaml)
66
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
77
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/tizz98/py-paas/tree/main/LICENSE)
8+
[![codecov](https://codecov.io/gh/tizz98/webhook-utils/branch/main/graph/badge.svg?token=HYT07K0ZHQ)](https://codecov.io/gh/tizz98/webhook-utils)
9+
[![PyPI pyversions](https://img.shields.io/pypi/pyversions/webhook-utils.svg)](https://pypi.python.org/pypi/webhook-utils/)
810

911
## Installation
1012

@@ -84,6 +86,34 @@ client = httpx.Client(auth=auth)
8486
client.post("https://example.com/webhook", json={"foo": "bar"})
8587
```
8688

89+
### FastAPI
90+
91+
`webhook-utils` has a built-in `WebhookRouter` class that can be used to
92+
wrap a `fastapi.APIRouter` to automatically verify incoming request signatures.
93+
94+
```shell
95+
pip install webhook-utils[fastapi]
96+
```
97+
98+
```python
99+
from fastapi import FastAPI, APIRouter
100+
from webhook_utils.contrib.fastapi import WebhookRouter
101+
102+
app = FastAPI()
103+
webhook_router = WebhookRouter(
104+
APIRouter(prefix="/webhooks"),
105+
webhook_key="secret",
106+
)
107+
108+
109+
@webhook_router.on("/demo-webhook")
110+
def demo_event_handler():
111+
return {"status": "ok"}
112+
113+
114+
app.include_router(webhook_router.api_router)
115+
```
116+
87117
## Publishing to PYPI
88118

89119
```shell

poetry.lock

Lines changed: 149 additions & 31 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "webhook-utils"
3-
version = "0.2.0"
3+
version = "0.3.0-dev"
44
description = "Short, well documented utilities for interacting with webhooks."
55
authors = ["Elijah Wilson <dev.tizz98@gmail.com>"]
66
license = "MIT"
@@ -11,9 +11,12 @@ keywords = ["webhook", "utils", "utilities", "webhooks"]
1111
[tool.poetry.dependencies]
1212
python = "^3.8"
1313
httpx = {version = "^0.21.1", optional = true}
14+
fastapi = {version = "^0.70.1", optional = true}
15+
requests = {version = "^2.26.0", optional = true}
1416

1517
[tool.poetry.extras]
1618
httpx = ["httpx"]
19+
fastapi = ["fastapi", "requests"]
1720

1821
[tool.poetry.dev-dependencies]
1922
black = "^21.12b0"

tests/contrib/test_fastapi.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import pytest
2+
from fastapi import APIRouter, FastAPI
3+
from fastapi.testclient import TestClient
4+
5+
from webhook_utils.contrib.fastapi import WebhookRouter
6+
from webhook_utils.crypto import compare_sha1_signature
7+
8+
9+
class TestWebhookRouter:
10+
@pytest.fixture
11+
def webhook_router(self):
12+
return WebhookRouter(APIRouter(prefix="/webhooks"), webhook_key="secret")
13+
14+
@pytest.fixture
15+
def app(self, webhook_router):
16+
app = FastAPI()
17+
18+
@webhook_router.on("/demo-webhook")
19+
def demo_webhook():
20+
return {"message": "Hello!"}
21+
22+
app.include_router(webhook_router.api_router)
23+
return app
24+
25+
@pytest.fixture
26+
def client(self, app):
27+
return TestClient(app)
28+
29+
def test_403_when_no_signature(self, client):
30+
response = client.post("/webhooks/demo-webhook", json={"foo": "bar"})
31+
assert response.status_code == 403
32+
33+
def test_401_when_invalid_signature(self, client):
34+
response = client.post(
35+
"/webhooks/demo-webhook",
36+
headers={"X-Webhook-Signature": "invalid"},
37+
json={"foo": "bar"},
38+
)
39+
assert response.status_code == 401
40+
41+
def test_200_when_valid_signature(self, client):
42+
expected_signature = "5910d971d6909f5bce3abc3e035e17faa72249399d6906248ce80dde30a32285" # noqa: E501
43+
response = client.post(
44+
"/webhooks/demo-webhook",
45+
headers={"X-Webhook-Signature": expected_signature},
46+
json={"foo": "bar"},
47+
)
48+
assert response.status_code == 200
49+
50+
def test_header_can_be_overridden(self, client, webhook_router):
51+
webhook_router._header_name = "X-Hub-Signature"
52+
expected_signature = "5910d971d6909f5bce3abc3e035e17faa72249399d6906248ce80dde30a32285" # noqa: E501
53+
response = client.post(
54+
"/webhooks/demo-webhook",
55+
headers={"X-Hub-Signature": expected_signature},
56+
json={"foo": "bar"},
57+
)
58+
assert response.status_code == 200
59+
60+
def test_signature_function_can_be_overridden(self, client, webhook_router):
61+
webhook_router._compare_signature_fn = compare_sha1_signature
62+
expected_signature = "0ff9bf78d3a75e3e45302ad860e8408b1129a190"
63+
response = client.post(
64+
"/webhooks/demo-webhook",
65+
headers={"X-Webhook-Signature": expected_signature},
66+
json={"foo": "bar"},
67+
)
68+
assert response.status_code == 200

tox.ini

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ commands =
5050

5151
[testenv]
5252
allowlist_externals = poetry
53+
extras =
54+
httpx
55+
fastapi
5356
setenv =
5457
PYTHONHASHSEED=0
5558
commands =
5659
poetry install
57-
pytest -n auto --spec --cov=webhook_utils/ {posargs}
60+
pytest -n auto --spec --cov-report=xml --cov=webhook_utils/ {posargs}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Webhook utilities for FastAPI."""
2+
3+
from .webhook_router import WebhookRouter # noqa: F401
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from typing import Callable
2+
3+
from fastapi import APIRouter, Depends, HTTPException, Request
4+
from fastapi.types import DecoratedCallable
5+
6+
from webhook_utils.crypto import compare_sha256_signature
7+
8+
9+
class WebhookRouter:
10+
"""Wraps a FastAPI router and adds a signature verification step for webhooks.
11+
12+
Example
13+
-------
14+
>>> from fastapi import FastAPI, APIRouter
15+
>>> app = FastAPI()
16+
>>> router = APIRouter(prefix="/webhooks")
17+
>>> webhook_router = WebhookRouter(router, webhook_key="secret")
18+
>>> @webhook_router.on("/demo-webhook")
19+
... def demo_event_handler(request: Request):
20+
... return {"status": "ok"}
21+
>>> app.include_router(webhook_router.api_router)
22+
"""
23+
24+
def __init__(
25+
self,
26+
api_router: APIRouter,
27+
*,
28+
webhook_key: str,
29+
header_name: str = "X-Webhook-Signature",
30+
compare_signature_fn: Callable[
31+
[bytes, bytes, str], bool
32+
] = compare_sha256_signature,
33+
) -> None:
34+
"""Initialize the WebhookRouter.
35+
36+
Parameters
37+
----------
38+
api_router: APIRouter
39+
The FastAPI router to wrap.
40+
webhook_key: str
41+
The webhook key to use for signature verification.
42+
header_name: str
43+
The name of the header to use for signature verification.
44+
Defaults to "X-Webhook-Signature".
45+
compare_signature_fn: Callable[[bytes, bytes, str], bool]
46+
The function to use for signature verification.
47+
Can be overridden for different signature algorithms.
48+
Defaults to `compare_sha256_signature`.
49+
"""
50+
51+
self._api_router = api_router
52+
self._webhook_key = webhook_key.encode("utf-8")
53+
self._header_name = header_name
54+
self._compare_signature_fn = compare_signature_fn
55+
56+
self._api_router.dependencies.append(Depends(self._validate_webhook_signature))
57+
58+
@property
59+
def api_router(self) -> APIRouter:
60+
"""Returns the internal FastAPI router."""
61+
return self._api_router
62+
63+
def on(self, *args, **kwargs) -> Callable[[DecoratedCallable], DecoratedCallable]:
64+
"""Adds an API route to the internal api router for this webhook event.
65+
66+
Parameters are the same as the `APIRouter.api_route` decorator.
67+
The default HTTP method is `POST` and is generally recommended to use.
68+
"""
69+
70+
kwargs.setdefault("methods", ["POST"])
71+
72+
def decorator(fn: DecoratedCallable) -> DecoratedCallable:
73+
return self._api_router.api_route(*args, **kwargs)(fn)
74+
75+
return decorator
76+
77+
async def _validate_webhook_signature(self, request: Request) -> None:
78+
"""Check if the webhook signature is valid.
79+
80+
This method is automatically added as a dependency to all routes
81+
created with `WebhookRouter.on`.
82+
83+
Raises HTTPException (401) if the signature is invalid.
84+
Raise HTTPException (403) if the signature is not set.
85+
"""
86+
87+
signature = request.headers.get(self._header_name)
88+
if not signature:
89+
raise HTTPException(status_code=403, detail="Signature not set")
90+
if not self._compare_signature_fn(
91+
self._webhook_key, await request.body(), signature
92+
):
93+
raise HTTPException(status_code=401, detail="Invalid signature")

webhook_utils/crypto/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"""Commonly used crypto functions for generating and comparing HMAC signatures."""
2+
13
from ._compare import ( # noqa: F401
24
compare_md5_signature,
35
compare_sha1_signature,

0 commit comments

Comments
 (0)