Files
api/services/nginx/app/classes/shelly.php
T

323 lines
9.3 KiB
PHP

<?php
namespace classes;
require_once WD . '/modules/shelly/shelly_c.php';
/** Actions */
require_once WD . '/modules/shelly/actions/shelly_search_a.php';
use Exception;
use interfaces\shelly_i;
use shelly\actions\shelly_search_a;
use shelly\shelly_c;
class shelly implements shelly_i
{
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';
/**
* Configuration of the shelly module
* @var shelly_c
*/
public shelly_c $config;
/**
* ACTION: shelly_SEARCH
* @see shelly_search_a
* @notation This action is when a company search request is made, and logs the request in the database
* @var shelly_search_a $shelly_search
*/
private shelly_search_a $shelly_search;
public function __construct()
{
$this->config = new shelly_c();
/** Actions */
$this->shelly_search = new shelly_search_a();
}
/**
* @inheritDoc
* @throws Exception If the module is not enabled, the license plate is invalid, the daily limit is exceeded, or the secret key is invalid
*/
function sendRequest(
string $endpoint,
array $data = [],
string $method = 'GET'
): object
{
// Validate the module is enabled
self::requireModuleEnabled();
// Validate the secret key
self::requireValidSecretKey();
// Send the request
$response = match ($method) {
'GET' => self::sendGetRequest($endpoint, $data),
'POST' => self::sendPostRequest($endpoint, $data),
//'PUT' => self::sendPutRequest($endpoint, $data),
//'DELETE' => self::sendDeleteRequest($endpoint, $data),
default => self::exception(
[
'method' => $method,
'endpoint' => $endpoint,
'data' => $data,
'response' => null,
'status_code' => 400,
'error' => 'Invalid request method',
],
500
),
};
// Add the usage to the request log
$this->shelly_search->shelly_search($response, 200);
// Return the response
return $response;
}
/**
* @inheritDoc
*/
function requireModuleEnabled(): void
{
// Check if the module is enabled
if (!$this->config->enabled->isTrue()) {
throw new Exception('The shelly module is not enabled');
}
}
/**
* @inheritDoc
*/
function requireValidSecretKey(): void
{
// Check if the secret key is valid
if ($this->config->secret_key->getVariableValue() === null) {
self::exception(
[
'status_code' => 500,
'error' => 'Invalid secret key defined in the config (shelly_secret_key_c)',
],
500
);
}
}
/**
* @throws Exception
*/
function exception(array $data, int $status_code = 500): object
{
// Check if the response is valid JSON
if (!json_decode($data['response'])) {
throw new Exception('Invalid response');
}
// Add the request to the log
$this->shelly_search->shelly_search($data, $status_code);;
return throw new Exception($data['error'] ?? 'An error occurred while processing the request, in ' . $this->config->getModuleName() . ' module');
}
/**
* @inheritDoc
* @throws Exception
*/
function requireValidServerURL(): void
{
// Check if the server URL is valid
if ($this->config->server_url->getVariableValue() === null) {
self::exception(
[
'status_code' => 500,
]
);
}
}
/**
* @inheritDoc
* @throws Exception
*/
function sendPostRequest(string $endpoint, array $data): array|object|null
{
/**
* curl -X POST 'https://<HOST>/v2/devices/api/get?auth_key=<AUTH_KEY>' \
* -H 'Content-Type: application/json' \
* -d '<BODY>'
*/
// Require the module to be enabled
self::requireModuleEnabled();
self::requireValidSecretKey();
self::requireValidServerURL();
$this->waitForShellyRateLimitWindow();
// Send the request
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
]);
// Execute the request
$response = curl_exec($ch);
// Get the status code
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
// Check for errors
if (curl_errno($ch)) {
self::exception(
[
'method' => 'POST',
'endpoint' => $endpoint,
'data' => $data,
'response' => $response,
],
$status_code
);
}
// Close the cURL session
curl_close($ch);
// Check if there is a response (some endpoints don't return a response)
if ($response === false) {
return null;
}
// Return the response
return json_decode($response);
}
/**
* @inheritDoc
* @throws Exception
*/
function sendGetRequest(string $endpoint, array $data): array|object|null
{
self::requireModuleEnabled();
self::requireValidSecretKey();
self::requireValidServerURL();
$this->waitForShellyRateLimitWindow();
$ch = curl_init();
curl_setopt(
$ch,
CURLOPT_URL,
self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint, $data)
);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPGET, true);
$response = curl_exec($ch);
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
self::exception(
[
'method' => 'GET',
'endpoint' => $endpoint,
'data' => $data,
'response' => $response,
],
$status_code
);
}
curl_close($ch);
if ($response === false) {
return null;
}
return json_decode($response);
}
function appendAuthKeyToQuery(string $url, array $query = []): string
{
$query['auth_key'] = $this->config->secret_key->getVariableValue();
$separator = str_contains($url, '?') ? '&' : '?';
return $url . $separator . http_build_query($query);
}
/**
* @throws Exception
*/
private function waitForShellyRateLimitWindow(): void
{
$deadline = $this->nowTimestamp() + self::SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS;
do {
if ($this->tryAcquireShellyRateLimitSlot()) {
return;
}
if ($this->nowTimestamp() >= $deadline) {
throw new Exception('Shelly rate limit gate wait timed out');
}
$remaining_ms = $this->getShellyRateLimitSlotRemainingMs();
if ($remaining_ms <= 0) {
$remaining_ms = 50;
}
$this->sleepMicroseconds(min($remaining_ms, 250) * 1000);
} while (true);
}
private function tryAcquireShellyRateLimitSlot(): bool
{
$redis = $this->redisFacade();
if ($redis === null) {
return true;
}
try {
$result = $redis->get_client()->set(
self::SHELLY_RATE_LIMIT_GATE_KEY,
(string)$this->nowTimestamp(),
'PX',
self::SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS,
'NX'
);
return $result === true || strtoupper((string)$result) === 'OK';
} catch (\Throwable) {
// If Redis gate can't be evaluated, fail open to avoid blocking API traffic completely.
return true;
}
}
private function getShellyRateLimitSlotRemainingMs(): int
{
$redis = $this->redisFacade();
if ($redis === null) {
return 0;
}
try {
$ttl = $redis->get_client()->pttl(self::SHELLY_RATE_LIMIT_GATE_KEY);
if (!is_numeric($ttl)) {
return 0;
}
$ttl = (int)$ttl;
return $ttl > 0 ? $ttl : 0;
} catch (\Throwable) {
return 0;
}
}
protected function redisFacade(): mixed
{
return defined('redis') ? redis : null;
}
protected function nowTimestamp(): float
{
return microtime(true);
}
protected function sleepMicroseconds(int $microseconds): void
{
if ($microseconds > 0) {
usleep($microseconds);
}
}
}