mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 09:17:53 +00:00
Allow sending webhooks via WS connection (#62725)
This commit is contained in:
parent
3f7275a9c7
commit
1ea3a17d89
@ -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 {
|
||||
|
@ -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),
|
||||
}
|
@ -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},
|
||||
},
|
||||
)
|
||||
|
@ -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),
|
||||
}
|
||||
|
@ -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"},
|
||||
}
|
@ -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
|
||||
|
@ -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"},
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user