Allow sending webhooks via WS connection (#62725)

This commit is contained in:
Paulus Schoutsen 2022-01-07 10:41:36 -08:00 committed by GitHub
parent 3f7275a9c7
commit 1ea3a17d89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 187 additions and 92 deletions

View File

@ -18,9 +18,9 @@ from homeassistant.components.google_assistant import const as gc, smart_home as
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
from homeassistant.util.aiohttp import MockRequest
from homeassistant.util.aiohttp import MockRequest, serialize_response
from . import alexa_config, google_config, utils
from . import alexa_config, google_config
from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN
from .prefs import CloudPreferences
@ -225,7 +225,7 @@ class CloudClient(Interface):
found["webhook_id"], request
)
response_dict = utils.aiohttp_serialize_response(response)
response_dict = serialize_response(response)
body = response_dict.get("body")
return {

View File

@ -1,25 +0,0 @@
"""Helper functions for cloud components."""
from __future__ import annotations
from typing import Any
from aiohttp import payload, web
def aiohttp_serialize_response(response: web.Response) -> dict[str, Any]:
"""Serialize an aiohttp response to a dictionary."""
if (body := response.body) is None:
body_decoded = None
elif isinstance(body, payload.StringPayload):
# pylint: disable=protected-access
body_decoded = body._value.decode(body.encoding)
elif isinstance(body, bytes):
body_decoded = body.decode(response.charset or "utf-8")
else:
raise ValueError("Unknown payload encoding")
return {
"status": response.status,
"body": body_decoded,
"headers": dict(response.headers),
}

View File

@ -17,7 +17,7 @@ from homeassistant.helpers.network import get_url
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util import network
from homeassistant.util.aiohttp import MockRequest
from homeassistant.util.aiohttp import MockRequest, serialize_response
_LOGGER = logging.getLogger(__name__)
@ -25,12 +25,6 @@ DOMAIN = "webhook"
URL_WEBHOOK_PATH = "/api/webhook/{webhook_id}"
WS_TYPE_LIST = "webhook/list"
SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): WS_TYPE_LIST}
)
@callback
@bind_hass
@ -134,9 +128,8 @@ async def async_handle_webhook(hass, webhook_id, request):
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the webhook component."""
hass.http.register_view(WebhookView)
hass.components.websocket_api.async_register_command(
WS_TYPE_LIST, websocket_list, SCHEMA_WS_LIST
)
hass.components.websocket_api.async_register_command(websocket_list)
hass.components.websocket_api.async_register_command(websocket_handle)
return True
@ -160,6 +153,11 @@ class WebhookView(HomeAssistantView):
put = _handle
@websocket_api.websocket_command(
{
"type": "webhook/list",
}
)
@callback
def websocket_list(hass, connection, msg):
"""Return a list of webhooks."""
@ -175,3 +173,39 @@ def websocket_list(hass, connection, msg):
]
connection.send_message(websocket_api.result_message(msg["id"], result))
@websocket_api.websocket_command(
{
vol.Required("type"): "webhook/handle",
vol.Required("webhook_id"): str,
vol.Required("method"): vol.In(["GET", "POST", "PUT"]),
vol.Optional("body", default=""): str,
vol.Optional("headers", default={}): {str: str},
vol.Optional("query", default=""): str,
}
)
@websocket_api.async_response
async def websocket_handle(hass, connection, msg):
"""Handle an incoming webhook via the WS API."""
request = MockRequest(
content=msg["body"].encode("utf-8"),
headers=msg["headers"],
method=msg["method"],
query_string=msg["query"],
mock_source=f"{DOMAIN}/ws",
)
response = await async_handle_webhook(hass, msg["webhook_id"], request)
response_dict = serialize_response(response)
body = response_dict.get("body")
connection.send_result(
msg["id"],
{
"body": body,
"status": response_dict["status"],
"headers": {"Content-Type": response.content_type},
},
)

View File

@ -7,6 +7,7 @@ import json
from typing import Any
from urllib.parse import parse_qsl
from aiohttp import payload, web
from multidict import CIMultiDict, MultiDict
@ -74,3 +75,22 @@ class MockRequest:
async def text(self) -> str:
"""Return the body as text."""
return self._text
def serialize_response(response: web.Response) -> dict[str, Any]:
"""Serialize an aiohttp response to a dictionary."""
if (body := response.body) is None:
body_decoded = None
elif isinstance(body, payload.StringPayload):
# pylint: disable=protected-access
body_decoded = body._value.decode(body.encoding)
elif isinstance(body, bytes):
body_decoded = body.decode(response.charset or "utf-8")
else:
raise ValueError("Unknown payload encoding")
return {
"status": response.status,
"body": body_decoded,
"headers": dict(response.headers),
}

View File

@ -1,54 +0,0 @@
"""Test aiohttp request helper."""
from aiohttp import web
from homeassistant.components.cloud import utils
def test_serialize_text():
"""Test serializing a text response."""
response = web.Response(status=201, text="Hello")
assert utils.aiohttp_serialize_response(response) == {
"status": 201,
"body": "Hello",
"headers": {"Content-Type": "text/plain; charset=utf-8"},
}
def test_serialize_body_str():
"""Test serializing a response with a str as body."""
response = web.Response(status=201, body="Hello")
assert utils.aiohttp_serialize_response(response) == {
"status": 201,
"body": "Hello",
"headers": {"Content-Length": "5", "Content-Type": "text/plain; charset=utf-8"},
}
def test_serialize_body_None():
"""Test serializing a response with a str as body."""
response = web.Response(status=201, body=None)
assert utils.aiohttp_serialize_response(response) == {
"status": 201,
"body": None,
"headers": {},
}
def test_serialize_body_bytes():
"""Test serializing a response with a str as body."""
response = web.Response(status=201, body=b"Hello")
assert utils.aiohttp_serialize_response(response) == {
"status": 201,
"body": "Hello",
"headers": {},
}
def test_serialize_json():
"""Test serializing a JSON response."""
response = web.json_response({"how": "what"})
assert utils.aiohttp_serialize_response(response) == {
"status": 200,
"body": '{"how": "what"}',
"headers": {"Content-Type": "application/json; charset=utf-8"},
}

View File

@ -3,6 +3,7 @@ from http import HTTPStatus
from ipaddress import ip_address
from unittest.mock import patch
from aiohttp import web
import pytest
from homeassistant.components import webhook
@ -206,3 +207,71 @@ async def test_listing_webhook(
"local_only": True,
},
]
async def test_ws_webhook(hass, caplog, hass_ws_client):
"""Test sending webhook msg via WS API."""
assert await async_setup_component(hass, "webhook", {})
received = []
async def handler(hass, webhook_id, request):
"""Handle a webhook."""
received.append(request)
return web.json_response({"from": "handler"})
webhook.async_register(hass, "test", "Test", "mock-webhook-id", handler)
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 5,
"type": "webhook/handle",
"webhook_id": "mock-webhook-id",
"method": "POST",
"headers": {"Content-Type": "application/json"},
"body": '{"hello": "world"}',
"query": "a=2",
}
)
result = await client.receive_json()
assert result["success"], result
assert result["result"] == {
"status": 200,
"body": '{"from": "handler"}',
"headers": {"Content-Type": "application/json"},
}
assert len(received) == 1
assert received[0].headers["content-type"] == "application/json"
assert received[0].query == {"a": "2"}
assert await received[0].json() == {"hello": "world"}
# Non existing webhook
caplog.clear()
await client.send_json(
{
"id": 6,
"type": "webhook/handle",
"webhook_id": "mock-nonexisting-id",
"method": "POST",
"body": '{"nonexisting": "payload"}',
}
)
result = await client.receive_json()
assert result["success"], result
assert result["result"] == {
"status": 200,
"body": None,
"headers": {"Content-Type": "application/octet-stream"},
}
assert (
"Received message for unregistered webhook mock-nonexisting-id from webhook/ws"
in caplog.text
)
assert '{"nonexisting": "payload"}' in caplog.text

View File

@ -1,4 +1,5 @@
"""Test aiohttp request helper."""
from aiohttp import web
from homeassistant.util import aiohttp
@ -25,3 +26,53 @@ async def test_request_post_query():
assert request.method == "POST"
assert await request.post() == {"hello": "2", "post": "true"}
assert request.query == {"get": "true"}
def test_serialize_text():
"""Test serializing a text response."""
response = web.Response(status=201, text="Hello")
assert aiohttp.serialize_response(response) == {
"status": 201,
"body": "Hello",
"headers": {"Content-Type": "text/plain; charset=utf-8"},
}
def test_serialize_body_str():
"""Test serializing a response with a str as body."""
response = web.Response(status=201, body="Hello")
assert aiohttp.serialize_response(response) == {
"status": 201,
"body": "Hello",
"headers": {"Content-Length": "5", "Content-Type": "text/plain; charset=utf-8"},
}
def test_serialize_body_None():
"""Test serializing a response with a str as body."""
response = web.Response(status=201, body=None)
assert aiohttp.serialize_response(response) == {
"status": 201,
"body": None,
"headers": {},
}
def test_serialize_body_bytes():
"""Test serializing a response with a str as body."""
response = web.Response(status=201, body=b"Hello")
assert aiohttp.serialize_response(response) == {
"status": 201,
"body": "Hello",
"headers": {},
}
def test_serialize_json():
"""Test serializing a JSON response."""
response = web.json_response({"how": "what"})
assert aiohttp.serialize_response(response) == {
"status": 200,
"body": '{"how": "what"}',
"headers": {"Content-Type": "application/json; charset=utf-8"},
}