Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions custom_components/pyscript/decorators/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ async def _handler(_hass, webhook_id, request):
func_args = {
"trigger_type": "webhook",
"webhook_id": webhook_id,
"request": request,
}

if "json" in request.headers.get(hdrs.CONTENT_TYPE, ""):
Expand Down
1 change: 1 addition & 0 deletions custom_components/pyscript/eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"payload",
"payload_obj",
"qos",
"request",
"retain",
"topic",
"trigger_type",
Expand Down
4 changes: 3 additions & 1 deletion custom_components/pyscript/stubs/pyscript_builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,12 @@ def webhook_trigger(

Args:
webhook_id: Webhook id to listen to.
str_expr: Optional expression evaluated against ``trigger_type``, ``webhook_id``, and ``payload``.
str_expr: Optional expression evaluated against ``trigger_type``, ``webhook_id``, ``request``, and ``payload``.
local_only: If False, allow requests from anywhere on the internet.
methods: HTTP methods to allow.
kwargs: Extra keyword arguments merged into each invocation.

Trigger kwargs include ``trigger_type="webhook"``, ``webhook_id``, the parsed payload fields, and ``request`` (the underlying ``aiohttp.web.Request``).
"""
...

Expand Down
20 changes: 20 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,7 @@ variables:
- ``trigger_type`` is set to "webhook"
- ``webhook_id`` is set to the webhook_id that was called.
- ``payload`` is the data/json that was sent in the request returned as a dictionary.
- ``request`` is the underlying ``aiohttp.web.Request``. Use it to inspect headers (e.g. for HMAC signature validation), the HTTP method, query string, or to re-read the raw body via ``await request.read()`` (the body is cached after pyscript parses it into ``payload``).

When the ``@webhook_trigger`` occurs, those same variables are passed as keyword arguments to the function in case it needs them. Additional keyword parameters can be specified by setting the optional ``kwargs`` argument to a ``dict`` with the keywords and values.

Expand All @@ -895,6 +896,25 @@ An simple example looks like

which if called using the curl command ``curl -X POST -d 'key1=xyz&key2=abc' hass_url/api/webhook/myid`` outputs ``It ran! {'key1': 'xyz', 'key2': 'abc'}, 10``

To validate an HMAC signature on incoming requests, declare ``request`` in the function and read the raw body:

.. code:: python

import hmac
import hashlib

SECRET = b"shared-secret"

@webhook_trigger("github")
def gh(payload, request):
sig = request.headers.get("X-Hub-Signature-256", "")
body = await request.read()
expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
log.warning("bad signature, ignoring")
return
log.info(f"verified webhook: {payload}")

NOTE: A webhook_id can only be used by either a built-in Home Assistant automation or pyscript, but not both. Trying to use the same webhook_id in both will result in an error.

@state_active
Expand Down
32 changes: 32 additions & 0 deletions tests/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
from custom_components.pyscript import trigger
from custom_components.pyscript.const import DOMAIN
from custom_components.pyscript.function import Function
from homeassistant.components import webhook
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED
from homeassistant.setup import async_setup_component
from homeassistant.util.aiohttp import MockRequest


async def setup_script(hass, notify_q, now, source):
Expand Down Expand Up @@ -224,3 +226,33 @@ def func6(value):
hass.states.async_set("pyscript.var1", 6 + 2 * i)
seq_num += 1
assert literal_eval(await wait_until_done(notify_q)) == [seq_num, 6 + 2 * i]


@pytest.mark.asyncio
async def test_webhook_request_kwarg(hass):
"""The aiohttp request is passed to the user function as the `request` kwarg."""
notify_q = asyncio.Queue(0)
await setup_script(
hass,
notify_q,
[dt(2020, 7, 1, 11, 59, 59, 999999)],
"""
@webhook_trigger("test_req_hook")
def webhook_test(payload, request):
pyscript.done = [request.headers["X-My-Sig"], request.method, payload]
""",
)
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()

request = MockRequest(
content=b'{"hello": "world"}',
mock_source="test",
method="POST",
headers={"Content-Type": "application/json", "X-My-Sig": "abc123"},
remote="127.0.0.1",
)

await webhook.async_handle_webhook(hass, "test_req_hook", request)

assert literal_eval(await wait_until_done(notify_q)) == ["abc123", "POST", {"hello": "world"}]
Loading