Last year I wrote a webhook listener using Flask-RESTPlus. Now I wrote the same using FastAPI:
import datetime
import hmac
import logging
from uuid import UUID
from fastapi import FastAPI, Header, Request, Response
from pydantic import BaseModel
class WebhookResponse(BaseModel):
result: str
class WebhookData(BaseModel):
username: str
data: dict
event: str
timestamp: datetime.datetime
model: str
request_id: UUID
app = FastAPI(
title="NetBox Webhook Listener",
description="Tested with NetBox 2.9.3",
version="1.0",
)
APP_NAME = "webhook-listener"
WEBHOOK_SECRET = "My precious"
logger = logging.getLogger(APP_NAME)
logger.setLevel(logging.INFO)
formatter = logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s")
file_logging = logging.FileHandler(f"{APP_NAME}.log")
file_logging.setFormatter(formatter)
logger.addHandler(file_logging)
def do_something_with_the_event(data):
logger.info("WebhookData received:")
logger.info(f"Raw data: {data}")
logger.info(f"Request ID: {data.request_id}")
logger.info(f"Username: {data.username}")
logger.info(f"Event: {data.event}")
logger.info(f"Timestamp: {data.timestamp}")
logger.info(f"Model: {data.model}")
logger.info(f"Data: {data.data}")
logger.info(f"URL in data: {data.data['url']}")
@app.post("/webhook/", response_model=WebhookResponse, status_code=200)
async def webhook(
webhook_input: WebhookData,
request: Request,
response: Response,
content_length: int = Header(...),
x_hook_signature: str = Header(None)
):
if content_length > 1_000_000:
# To prevent memory allocation attacks
logger.error(f"Content too long ({content_length})")
response.status_code = 400
return {"result": "Content too long"}
if x_hook_signature:
raw_input = await request.body()
input_hmac = hmac.new(
key=WEBHOOK_SECRET.encode(),
msg=raw_input,
digestmod="sha512"
)
if not hmac.compare_digest(
input_hmac.hexdigest(),
x_hook_signature
):
logger.error("Invalid message signature")
response.status_code = 400
return {"result": "Invalid message signature"}
logger.info("Message signature checked ok")
else:
logger.info("No message signature to check")
do_something_with_the_event(webhook_input)
return {"result": "ok"}
There is so much to unpack there! Some of the FastAPI features I used (there are some links to FastAPI documentation):
- Request validation: I defined my own
WebhookDataclass that defines the expected data in the webhook request. For example, I’m expecting that a JSON fieldrequest_idis an UUID string, and thatdatafield is a dictionary. - Response validation: I defined the class
WebhookResponseto describe that my responses to NetBox will just contain aresultstring. - The
requestobject contains the raw request. I needed that data to be able to calculate the signature for the request. Without that I wouldn’t have needed the raw request at all, I could just read the FastAPI-validated fields inwebhook_input(as demonstrated in thedo_something_with_the_event(webhook_input)call). - The interesting headers were defined in the function arguments.
Let’s see what is the output like:
2020-10-26 20:14:18,812 webhook-listener INFO: Message signature checked ok
2020-10-26 20:14:18,812 webhook-listener INFO: WebhookData received:
2020-10-26 20:14:18,812 webhook-listener INFO: Raw data: username='admin' data={'id': 14, 'url': '/api/dcim/sites/14/', 'name': 'Test_site', 'slug': 'test_site', 'status': {'value': 'active', 'label': 'Active'}, 'region': None, 'tenant': None, 'facility': '', 'asn': None, 'time_zone': None, 'description': '', 'physical_address': '', 'shipping_address': '', 'latitude': None, 'longitude': None, 'contact_name': '', 'contact_phone': '', 'contact_email': '', 'comments': '', 'tags': [], 'custom_fields': {}, 'created': '2020-10-26', 'last_updated': '2020-10-26T20:14:18.744032+02:00'} event='created' timestamp=datetime.datetime(2020, 10, 26, 18, 14, 18, 764443, tzinfo=datetime.timezone.utc) model='site' request_id=UUID('b3dc3247-883f-4810-ad01-0f50ee6b722d')
2020-10-26 20:14:18,812 webhook-listener INFO: Request ID: b3dc3247-883f-4810-ad01-0f50ee6b722d
2020-10-26 20:14:18,812 webhook-listener INFO: Username: admin
2020-10-26 20:14:18,812 webhook-listener INFO: Event: created
2020-10-26 20:14:18,812 webhook-listener INFO: Timestamp: 2020-10-26 18:14:18.764443+00:00
2020-10-26 20:14:18,812 webhook-listener INFO: Model: site
2020-10-26 20:14:18,812 webhook-listener INFO: Data: {'id': 14, 'url': '/api/dcim/sites/14/', 'name': 'Test_site', 'slug': 'test_site', 'status': {'value': 'active', 'label': 'Active'}, 'region': None, 'tenant': None, 'facility': '', 'asn': None, 'time_zone': None, 'description': '', 'physical_address': '', 'shipping_address': '', 'latitude': None, 'longitude': None, 'contact_name': '', 'contact_phone': '', 'contact_email': '', 'comments': '', 'tags': [], 'custom_fields': {}, 'created': '2020-10-26', 'last_updated': '2020-10-26T20:14:18.744032+02:00'}
2020-10-26 20:14:18,812 webhook-listener INFO: URL in data: /api/dcim/sites/14/
In the output you can first see the “raw” contents of the validated input data. See how it shows the timestamp and request_id fields with the Python data types! Then it printed each field one by one, and finally demonstrated how to access one field (the URL) from the data field of the webhook request.
I must say I’m impressed: This FastAPI thingy looks nice and useful!
To run the code you will need for example uvicorn (that requires also uvloop and httptools to be installed) and gunicorn. I’ve described the running setup in the next post.
can you please give me some tips about line 59 to 69?
They are specifically related to NetBox webhooks and specifying a secret in the webhook configuration. From NetBox GUI: “When [secret is] provided, the request will include a ‘X-Hook-Signature’ header containing a HMAC hex digest of the payload body using the secret as the key. The secret is not transmitted in the request.”