Skip to content

Commit

Permalink
- Added HaWebhookClient.
Browse files Browse the repository at this point in the history
- Moved request helpers into trait.
- Created HaWebhookClient.
- Created HaWebhookClientTest.
- Created WebhookSuccess response definition.
  • Loading branch information
IndexZer0 committed Feb 27, 2024
1 parent bd147c1 commit dd41b63
Show file tree
Hide file tree
Showing 5 changed files with 465 additions and 68 deletions.
71 changes: 3 additions & 68 deletions src/HaRestApiClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,18 @@
namespace IndexZer0\HaRestApiClient;

use DateTimeInterface;
use Http\Client\Common\Exception\ClientErrorException;
use Http\Client\Common\Plugin\AuthenticationPlugin;
use Http\Client\Common\Plugin\BaseUriPlugin;
use Http\Client\Common\Plugin\ErrorPlugin;
use Http\Client\Common\Plugin\HeaderDefaultsPlugin;
use Http\Message\Authentication\Bearer;
use IndexZer0\HaRestApiClient\HttpClient\Builder;
use JsonException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Throwable;
use IndexZer0\HaRestApiClient\Traits\HandlesRequests;

class HaRestApiClient
{
use HandlesRequests;

private static string $dateFormat = 'Y-m-d\Th:m:sP';

public function __construct(
Expand Down Expand Up @@ -318,67 +316,4 @@ public function handleIntent(array $data): array
)
);
}

/**
* ---------------------------------------------------------------------------------
* Helpers
* ---------------------------------------------------------------------------------
*/

/*
* Send request and handle responses.
*/
private function handleRequest(RequestInterface $request): array
{
try {
$response = $this->httpClientBuilder->getHttpClient()->sendRequest($request);
} catch (ClientErrorException $ce) {
throw new HaException($ce->getResponse()->getBody()->getContents(), previous: $ce);
} catch (Throwable $t) {
throw new HaException('Unknown Error.', previous: $t);
}

$responseBodyContent = $response->getBody()->getContents();

$responseContentType = $this->getContentTypeFromResponse($response) ?? 'application/json';

if ($responseContentType === 'application/json') {
try {
$json = json_decode($responseBodyContent, true, flags: JSON_THROW_ON_ERROR);

// This is a failsafe for if the home assistant json response is not an array when decoded
// For example if $responseBodyContent = 'null';
// Not seen this scenario in the wild but handling this json decode case anyway.
if (!is_array($json)) {
return [$json];
}

return $json;
} catch (JsonException $je) {
// This should never happen.
// If it does, it means home assistant is returning invalid json with application/json Content-Type header.
throw new HaException('Invalid JSON Response.', previous: $je);
}
}

// Some responses come back with Content-Type header of text/plain.
// Such as errorLog and renderTemplate.
// So lets just wrap in an array to satisfy return type and keep api consistent.
return [
'response' => $responseBodyContent
];
}

private function getContentTypeFromResponse(ResponseInterface $response): ?string
{
return $response->hasHeader('Content-Type') ? $response->getHeader('Content-Type')[0] : null;
}

private function createRequestWithQuery(string $method, $uri, array $query): RequestInterface
{
$request = $this->httpClientBuilder->getRequestFactory()->createRequest($method, $uri);
return $request->withUri(
$request->getUri()->withQuery(http_build_query($query))
);
}
}
99 changes: 99 additions & 0 deletions src/HaWebhookClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace IndexZer0\HaRestApiClient;

use Http\Client\Common\Plugin\BaseUriPlugin;
use Http\Client\Common\Plugin\ErrorPlugin;
use IndexZer0\HaRestApiClient\HttpClient\Builder;
use IndexZer0\HaRestApiClient\Traits\HandlesRequests;

class HaWebhookClient
{
use HandlesRequests;

private array $supportedPayloadTypes = [
'json',
'form_params',
];

private array $supportedHttpMethods = [
'GET',
'HEAD',
'PUT',
'POST'
];

public function __construct(
private string $baseUri,
public readonly Builder $httpClientBuilder = new Builder(),
) {
$this->httpClientBuilder->addPlugin(new BaseUriPlugin(
$this->httpClientBuilder->getUriFactory()->createUri($this->baseUri),
[
// Always replace the host, even if this one is provided on the sent request. Available for AddHostPlugin.
'replace' => true,
]
));
$this->httpClientBuilder->addPlugin(new ErrorPlugin());
}

/*
* https://www.home-assistant.io/docs/automation/trigger/#webhook-trigger
* Webhooks support HTTP POST, PUT, HEAD, and GET requests.
*
* Note that a given webhook can only be used in one automation at a time. That is, only one automation trigger can use a specific webhook ID.
*
* https://www.home-assistant.io/docs/automation/trigger/#webhook-data
*
* Payloads may either be encoded as form data or JSON.
* Depending on that, its data will be available in an automation template as either trigger.data or trigger.json
* URL query parameters are also available in the template as trigger.query.
*
* Note that to use JSON encoded payloads, the Content-Type header must be set to application/json
*
* https://www.home-assistant.io/docs/automation/trigger/#webhook-security
*
* ----------------------------------------------------------------------------
*
* See templating for webhooks in automations.
* https://www.home-assistant.io/docs/automation/templating/#all
* https://www.home-assistant.io/docs/automation/templating/#webhook
*/
public function send(
string $method,
string $webhookId,
?array $queryParams = null,
?string $payloadType = null,
?array $data = null,
): array {
if (!in_array($method, $this->supportedHttpMethods, true)) {
throw new HaException("\$method must be one of: " . join(', ', $this->supportedHttpMethods));
}

$request = $this->createRequestWithQuery($method, "/webhook/{$webhookId}", $queryParams ?? []);

if ($payloadType !== null) {
if (!in_array($payloadType, $this->supportedPayloadTypes, true)) {
throw new HaException("\$payloadType must be one of: " . join(', ', $this->supportedPayloadTypes));
}

if ($data === null) {
throw new HaException("\$data must be provided when providing \$payloadType");
}

$request = $request->withHeader('Content-Type', $payloadType === 'json' ? 'application/json' : 'application/x-www-form-urlencoded');

$request = $request->withBody(
$this->httpClientBuilder->getStreamFactory()->createStream(
$payloadType === 'json' ? json_encode($data) : http_build_query($data, '', '&')
)
);
}

return $this->handleRequest(
$request
);
}
}
72 changes: 72 additions & 0 deletions src/Traits/HandlesRequests.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace IndexZer0\HaRestApiClient\Traits;

use Http\Client\Common\Exception\ClientErrorException;
use IndexZer0\HaRestApiClient\HaException;
use JsonException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Throwable;

trait HandlesRequests
{
/*
* Send request and handle responses.
*/
private function handleRequest(RequestInterface $request): array
{
try {
$response = $this->httpClientBuilder->getHttpClient()->sendRequest($request);
} catch (ClientErrorException $ce) {
throw new HaException($ce->getResponse()->getBody()->getContents(), previous: $ce);
} catch (Throwable $t) {
throw new HaException('Unknown Error.', previous: $t);
}

$responseBodyContent = $response->getBody()->getContents();

$responseContentType = $this->getContentTypeFromResponse($response) ?? 'application/json';

if ($responseContentType === 'application/json') {
try {
$json = json_decode($responseBodyContent, true, flags: JSON_THROW_ON_ERROR);

// This is a failsafe for if the home assistant json response is not an array when decoded
// For example if $responseBodyContent = 'null';
// Not seen this scenario in the wild but handling this json decode case anyway.
if (!is_array($json)) {
return [$json];
}

return $json;
} catch (JsonException $je) {
// This should never happen.
// If it does, it means home assistant is returning invalid json with application/json Content-Type header.
throw new HaException('Invalid JSON Response.', previous: $je);
}
}

// Some responses come back with Content-Type header of text/plain.
// Such as errorLog and renderTemplate.
// So lets just wrap in an array to satisfy return type and keep api consistent.
return [
'response' => $responseBodyContent
];
}

private function getContentTypeFromResponse(ResponseInterface $response): ?string
{
return $response->hasHeader('Content-Type') ? $response->getHeader('Content-Type')[0] : null;
}

private function createRequestWithQuery(string $method, $uri, array $query): RequestInterface
{
$request = $this->httpClientBuilder->getRequestFactory()->createRequest($method, $uri);
return $request->withUri(
$request->getUri()->withQuery(http_build_query($query))
);
}
}

0 comments on commit dd41b63

Please sign in to comment.