diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 5f3abe521af..0d10152a0a5 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -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 { diff --git a/homeassistant/components/cloud/utils.py b/homeassistant/components/cloud/utils.py deleted file mode 100644 index 5908a0ac816..00000000000 --- a/homeassistant/components/cloud/utils.py +++ /dev/null @@ -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), - } diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 645af24bce2..1988a1b1a58 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -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}, + }, + ) diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py index b23e5cf29e8..aa1aea1abc3 100644 --- a/homeassistant/util/aiohttp.py +++ b/homeassistant/util/aiohttp.py @@ -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), + } diff --git a/tests/components/cloud/test_utils.py b/tests/components/cloud/test_utils.py deleted file mode 100644 index d23b99cbb5d..00000000000 --- a/tests/components/cloud/test_utils.py +++ /dev/null @@ -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"}, - } diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py index 5114bab5634..c7ed1a23985 100644 --- a/tests/components/webhook/test_init.py +++ b/tests/components/webhook/test_init.py @@ -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 diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py index e7b5ef73c32..cd156705ddf 100644 --- a/tests/util/test_aiohttp.py +++ b/tests/util/test_aiohttp.py @@ -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"}, + }