diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php index 813ac981..7c02302c 100644 --- a/src/Database/Barry/Model.php +++ b/src/Database/Barry/Model.php @@ -337,7 +337,7 @@ public static function retrieveAndDelete( } if ($model instanceof Collection) { - $model->dropAll(); + $model->delete(); return $model; } @@ -904,56 +904,7 @@ public function __get(string $name): mixed return null; } - if (in_array($name, $this->mutableDateAttributes())) { - return new Carbon($this->attributes[$name]); - } - - if (array_key_exists($name, $this->casts)) { - $type = $this->casts[$name]; - $value = $this->attributes[$name]; - if ($type === "date") { - return new Carbon($value); - } - if ($type === "int") { - return (int)$value; - } - if ($type === "float") { - return (float)$value; - } - if ($type === "double") { - return (double)$value; - } - if ($type === "json") { - if (is_array($value)) { - return (object)$value; - } - if (is_object($value)) { - return (object)$value; - } - return json_decode( - $value, - false, - 512, - JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE - ); - } - if ($type === "array") { - if (is_array($value)) { - return (array)$value; - } - if (is_object($value)) { - return (array)$value; - } - return json_decode( - $value, - true, - 512, - JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE - ); - } - } - - return $this->attributes[$name]; + return $this->executeDataCasting($name); } /** @@ -968,28 +919,29 @@ public function __set(string $name, mixed $value) } /** - * Lists of mutable properties + * __toString * - * @return array + * @return string */ - private function mutableDateAttributes(): array + public function __toString(): string { - return array_merge( - $this->dates, - [ - $this->created_at, $this->updated_at, 'expired_at', 'logged_at', 'signed_at' - ] - ); + foreach ($this->attributes as $name => $value) { + $this->attributes[$name] = $this->executeDataCasting($name); + } + + return $this->toJson(); } /** - * __toString + * Lists of mutable properties * - * @return string + * @return array */ - public function __toString(): string + private function mutableDateAttributes(): array { - return $this->toJson(); + return array_merge($this->dates, [ + $this->created_at, $this->updated_at, 'expired_at', 'logged_at', 'signed_at' + ]); } /** @@ -999,13 +951,11 @@ public function __toString(): string */ public function toJson(): string { - $data = array_filter( - $this->attributes, - function ($key) { - return !in_array($key, $this->hidden); - }, - ARRAY_FILTER_USE_KEY - ); + foreach ($this->attributes as $name => $value) { + $this->attributes[$name] = $this->executeDataCasting($name); + } + + $data = array_filter($this->attributes, fn ($key) => !in_array($key, $this->hidden), ARRAY_FILTER_USE_KEY); return json_encode($data); } @@ -1025,9 +975,74 @@ public function __call(string $name, array $arguments = []) return call_user_func_array([$model, $name], $arguments); } - throw new BadMethodCallException( - 'method ' . $name . ' is not defined.', - E_ERROR - ); + throw new BadMethodCallException('Method ' . $name . ' is not defined.', E_ERROR); + } + + /** + * Executes data casting for a given attribute name + * + * @param string $name + * @return mixed + */ + private function executeDataCasting(string $name): mixed + { + if (in_array($name, $this->mutableDateAttributes())) { + return new Carbon($this->attributes[$name]); + } + + if (!array_key_exists($name, $this->casts)) { + return $this->attributes[$name]; + } + + $type = $this->casts[$name]; + $value = $this->attributes[$name]; + + if ($type === "date") { + return new Carbon($value); + } + + if ($type === "int") { + return (int)$value; + } + + if ($type === "float") { + return (float)$value; + } + + if ($type === "double") { + return (float)$value; + } + + if ($type === "json") { + if (is_array($value)) { + return (object)$value; + } + if (is_object($value)) { + return (object)$value; + } + return json_decode( + $value, + false, + 512, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE + ); + } + + if ($type === "array") { + if (is_array($value)) { + return (array) $value; + } + if (is_object($value)) { + return (array) $value; + } + return json_decode( + $value, + true, + 512, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE + ); + } + + return $this->attributes[$name]; } } diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index cf8e16e9..0c398fd1 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -45,6 +45,27 @@ class HttpClient */ private ?string $base_url = null; + /** + * The request timeout in seconds + * + * @var int|null + */ + private ?int $timeout = null; + + /** + * The connection timeout in seconds + * + * @var int|null + */ + private ?int $connect_timeout = null; + + /** + * Whether to verify SSL certificates + * + * @var bool + */ + private bool $verify_ssl = true; + /** * HttpClient Constructor. * @@ -121,6 +142,19 @@ private function applyCommonOptions(): void curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($this->ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($this->ch, CURLOPT_AUTOREFERER, true); + + if ($this->timeout !== null) { + curl_setopt($this->ch, CURLOPT_TIMEOUT, $this->timeout); + } + + if ($this->connect_timeout !== null) { + curl_setopt($this->ch, CURLOPT_CONNECTTIMEOUT, $this->connect_timeout); + } + + if (!$this->verify_ssl) { + curl_setopt($this->ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($this->ch, CURLOPT_SSL_VERIFYHOST, false); + } } /** @@ -311,4 +345,89 @@ public function withHeaders(array $headers): HttpClient return $this; } + + /** + * Set HTTP authentication credentials + * + * @param string $username + * @param string $password + * @return HttpClient + */ + public function auth(string $username, string $password): HttpClient + { + curl_setopt($this->ch, CURLOPT_USERPWD, $username . ":" . $password); + + return $this; + } + + /** + * Set Basic HTTP authentication + * + * @param string $key + * @param string $secret + * @return HttpClient + */ + public function basicAuth(string $key, string $secret): HttpClient + { + $this->withHeaders([ + 'Authorization' => 'Basic ' . base64_encode($key . ':' . $secret) + ]); + + return $this; + } + + /** + * Set Bearer token authentication + * + * @param string $token + * @return HttpClient + */ + public function bearerAuth(string $token): HttpClient + { + $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token + ]); + + return $this; + } + + /** + * Set the maximum time the request is allowed to take + * + * @param int $seconds + * @return HttpClient + */ + public function timeout(int $seconds): HttpClient + { + $this->timeout = $seconds; + + return $this; + } + + /** + * Set the maximum time to wait for a connection + * + * @param int $seconds + * @return HttpClient + */ + public function connectTimeout(int $seconds): HttpClient + { + $this->connect_timeout = $seconds; + + return $this; + } + + /** + * Disable SSL certificate verification + * + * Warning: This should only be used in development environments + * + * @return HttpClient + */ + public function disableSslVerification(): HttpClient + { + $this->verify_ssl = false; + + return $this; + } } diff --git a/src/Notifier/Adapters/SmsChannelAdapter.php b/src/Notifier/Adapters/SmsChannelAdapter.php index b32ca278..eedc44a3 100644 --- a/src/Notifier/Adapters/SmsChannelAdapter.php +++ b/src/Notifier/Adapters/SmsChannelAdapter.php @@ -3,6 +3,7 @@ namespace Bow\Notifier\Adapters; use Bow\Database\Barry\Model; +use Bow\Http\Client\HttpClient; use Bow\Notifier\Contracts\ChannelAdapterInterface; use Bow\Notifier\Notifier; use InvalidArgumentException; @@ -12,14 +13,23 @@ class SmsChannelAdapter implements ChannelAdapterInterface { /** - * @var Client + * @var string */ - private Client $client; + private string $from_number; /** + * The SMS provider + * * @var string */ - private string $from_number; + private string $sms_provider; + + /** + * The configuration array + * + * @var array + */ + private array $setting; /** * Constructor @@ -28,15 +38,9 @@ class SmsChannelAdapter implements ChannelAdapterInterface */ public function __construct() { - $account_sid = config('messaging.twilio.account_sid'); - $auth_token = config('messaging.twilio.auth_token'); - $this->from_number = config('messaging.twilio.from'); - - if (!$account_sid || !$auth_token || !$this->from_number) { - throw new InvalidArgumentException('Twilio credentials are required'); - } - - $this->client = new Client($account_sid, $auth_token); + $config = config('notifier.sms'); + $this->setting = $config['setting'] ?? []; + $this->sms_provider = $config['provider'] ?? 'callisto'; } /** @@ -52,7 +56,15 @@ public function send(Model $context, Notifier $notifier): void return; } - $this->sendWithTwilio($context, $notifier); + if ($this->sms_provider === 'twilio') { + $this->sendWithTwilio($context, $notifier); + return; + } + + if ($this->sms_provider === 'callisto') { + $this->sendWithCallisto($context, $notifier); + return; + }; } /** @@ -66,27 +78,70 @@ private function sendWithTwilio(Model $context, Notifier $notifier): void { $data = $notifier->toSms($context); - $account_sid = config('notifier.twilio.account_sid'); - $auth_token = config('notifier.twilio.auth_token'); - $this->from_number = config('notifier.twilio.from'); + $account_sid = $this->setting['account_sid'] ?? null; + $auth_token = $this->setting['auth_token'] ?? null; + $this->from_number = $this->setting['from'] ?? null; if (!$account_sid || !$auth_token || !$this->from_number) { throw new InvalidArgumentException('Twilio credentials are required'); } - $this->client = new Client($account_sid, $auth_token); - if (!isset($data['to']) || !isset($data['message'])) { throw new InvalidArgumentException('The phone number and notifier are required'); } try { - $this->client->notifiers->create($data['to'], [ + $client = new Client($account_sid, $auth_token); + $client->messages->create($data['to'], [ 'from' => $this->from_number, - 'body' => $data['notifier'] + 'body' => $data['message'] ]); } catch (\Exception $e) { throw new \RuntimeException('Error while sending SMS: ' . $e->getMessage()); } } + + /** + * Send the notifier via SMS using Callisto + * + * @param Model $context + * @param Notifier $notifier + * @return void + */ + private function sendWithCallisto(Model $context, Notifier $notifier): void + { + $access_key = $this->setting['access_key'] ?? null; + $access_secret = $this->setting['access_secret'] ?? null; + $notify_url = $this->setting['notify_url'] ?? null; + + if (!$access_key || !$access_secret) { + throw new InvalidArgumentException('Callisto credentials are required'); + } + + $data = $notifier->toSms($context); + + if (!isset($data['to']) || !isset($data['message']) || !isset($data['sender'])) { + throw new InvalidArgumentException('The phone number and notifier are required'); + } + + $client = new HttpClient('https://api.callistosms.com'); + + if (!isset($data['notify_url'])) { + $data['notify_url'] = $notify_url; + } + + $payload = [ + 'to' => (array) $data['to'], + 'message' => $data['message'], + 'sender' => $data['sender'], + ]; + + if ($data['notify_url']) { + $payload['notify_url'] = $data['notify_url']; + } + + $client->basicAuth($access_key, $access_secret) + ->acceptJson() + ->post('v1/sms/send', $payload); + } } diff --git a/tests/Database/NotificationDatabaseTest.php b/tests/Database/NotificationDatabaseTest.php index 731d0c2a..f189cefc 100644 --- a/tests/Database/NotificationDatabaseTest.php +++ b/tests/Database/NotificationDatabaseTest.php @@ -17,7 +17,7 @@ public static function setUpBeforeClass(): void Database::statement("drop table if exists notifications;"); $driver = $config["database"]["default"]; - $idColumn = $driver === 'pgsql' ? 'id SERIAL PRIMARY KEY' : ($driver === 'mysql' ? 'id INTEGER PRIMARY KEY AUTO_INCREMENT' : 'id INTEGER PRIMARY KEY AUTOINCREMENT'); + $idColumn = $driver === 'pgsql' ? 'id SERIAL PRIMARY KEY' : ($driver === 'mysql' ? 'id INT PRIMARY KEY AUTO_INCREMENT' : 'id INTEGER PRIMARY KEY AUTOINCREMENT'); Database::statement("create table if not exists notifications ( $idColumn, type text null, @@ -27,7 +27,7 @@ public static function setUpBeforeClass(): void read_at TIMESTAMP null, created_at timestamp null default current_timestamp, updated_at timestamp null default current_timestamp, - deleted_at TIMESTAMP null + deleted_at timestamp null );"); } diff --git a/tests/Support/HttpClientTest.php b/tests/Support/HttpClientTest.php index 9f40d100..618ca0f1 100644 --- a/tests/Support/HttpClientTest.php +++ b/tests/Support/HttpClientTest.php @@ -170,4 +170,126 @@ public function test_redirect_following() $this->assertEquals(200, $response->statusCode()); } + + // ==================== Authentication Tests ==================== + + public function test_basic_auth_with_valid_credentials() + { + $http = new HttpClient(); + $http->basicAuth('user', 'passwd'); + + $response = $http->get("https://httpbin.org/basic-auth/user/passwd"); + + $this->assertEquals(200, $response->statusCode()); + $this->assertStringContainsString('authenticated', $response->getContent()); + } + + public function test_basic_auth_with_invalid_credentials() + { + $http = new HttpClient(); + $http->basicAuth('wrong', 'credentials'); + + $response = $http->get("https://httpbin.org/basic-auth/user/passwd"); + + $this->assertEquals(401, $response->statusCode()); + } + + public function test_bearer_auth_sends_token_in_header() + { + $http = new HttpClient(); + $http->bearerAuth('my-test-token'); + + $response = $http->get("https://httpbin.org/bearer"); + + $this->assertEquals(200, $response->statusCode()); + $this->assertStringContainsString('authenticated', $response->getContent()); + } + + public function test_bearer_auth_fails_without_token() + { + $http = new HttpClient(); + + $response = $http->get("https://httpbin.org/bearer"); + + $this->assertEquals(401, $response->statusCode()); + } + + // ==================== Accept JSON Tests ==================== + + public function test_accept_json_sets_content_type_header() + { + $http = new HttpClient(); + $http->acceptJson(); + + $response = $http->post("https://httpbin.org/post", [ + 'name' => 'test', + 'value' => 'example' + ]); + + $this->assertEquals(200, $response->statusCode()); + $content = json_decode($response->getContent(), true); + $this->assertEquals('application/json', $content['headers']['Content-Type']); + } + + // ==================== Timeout Configuration Tests ==================== + + public function test_connect_timeout_configuration() + { + $http = new HttpClient(); + $http->connectTimeout(5); + + $response = $http->get("https://httpbin.org/get"); + + $this->assertEquals(200, $response->statusCode()); + } + + public function test_timeout_configuration() + { + $http = new HttpClient(); + $http->timeout(10); + + $response = $http->get("https://httpbin.org/get"); + + $this->assertEquals(200, $response->statusCode()); + } + + // ==================== SSL Verification Tests ==================== + + public function test_disable_ssl_verification() + { + $http = new HttpClient(); + $http->disableSslVerification(); + + $response = $http->get("https://httpbin.org/get"); + + $this->assertEquals(200, $response->statusCode()); + } + + // ==================== Base URL Tests ==================== + + public function test_set_base_url_method() + { + $http = new HttpClient(); + $http->setBaseUrl("https://httpbin.org"); + + $response = $http->get("/get"); + + $this->assertEquals(200, $response->statusCode()); + } + + // ==================== Method Chaining Tests ==================== + + public function test_method_chaining() + { + $http = new HttpClient(); + + $response = $http + ->withHeaders(['X-Custom' => 'value']) + ->acceptJson() + ->timeout(10) + ->connectTimeout(5) + ->post("https://httpbin.org/post", ['key' => 'value']); + + $this->assertEquals(200, $response->statusCode()); + } }