From 9b25a4a26e6a9c9a56c4248e316fb5a5a21e5678 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Sat, 25 Oct 2025 00:56:18 +0545 Subject: [PATCH 01/34] feat: adds HamroCDNContract interface and PHPStan type definition --- src/Contracts/HamroCDNContract.php | 32 ++++++++++++++++++++++++++++++ src/HamroCDN.php | 6 ++++++ 2 files changed, 38 insertions(+) create mode 100644 src/Contracts/HamroCDNContract.php diff --git a/src/Contracts/HamroCDNContract.php b/src/Contracts/HamroCDNContract.php new file mode 100644 index 0000000..1be69a3 --- /dev/null +++ b/src/Contracts/HamroCDNContract.php @@ -0,0 +1,32 @@ + Date: Sat, 25 Oct 2025 01:05:14 +0545 Subject: [PATCH 02/34] feat: implements HamroCDNContract in HamroCDN class & returns dummy data --- src/HamroCDN.php | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/HamroCDN.php b/src/HamroCDN.php index 403e5f7..9ff2a9a 100644 --- a/src/HamroCDN.php +++ b/src/HamroCDN.php @@ -4,13 +4,34 @@ namespace HamroCDN; +use HamroCDN\Contracts\HamroCDNContract; + /** * @phpstan-type HamroCDNObject array{ * id: string, * nanoId: string, * } */ -final class HamroCDN +final class HamroCDN implements HamroCDNContract { - // + public function upload(string $filePath): array + { + return [ + 'id' => 'sample_id', + 'nanoId' => 'sample_nano_id', + ]; + } + + public function fetch(string $id): array + { + return [ + 'id' => 'sample_id', + 'nanoId' => 'sample_nano_id', + ]; + } + + public function delete(string $id): bool + { + return true; + } } From 968733c5fee9701a3ae913a7881d0b0680fd4b71 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Sat, 25 Oct 2025 01:07:31 +0545 Subject: [PATCH 03/34] feat: adds interface checks for HamroCDN contracts in ArchTest --- tests/ArchTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/ArchTest.php b/tests/ArchTest.php index b8bf9b7..45c3dbf 100644 --- a/tests/ArchTest.php +++ b/tests/ArchTest.php @@ -12,3 +12,8 @@ ->expect('HamroCDN') ->classes() ->toBeFinal(); + +arch('all contracts are interfaces') + ->expect('HamroCDN\Contracts') + ->classes() + ->toBeInterfaces(); From 70a9a17792aa0c1d60d81cd3d14d990dfe6dcec9 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Sat, 25 Oct 2025 01:08:38 +0545 Subject: [PATCH 04/34] feat: adds interface implementation check for HamroCDN class --- tests/ArchTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/ArchTest.php b/tests/ArchTest.php index 45c3dbf..5e5ea50 100644 --- a/tests/ArchTest.php +++ b/tests/ArchTest.php @@ -17,3 +17,7 @@ ->expect('HamroCDN\Contracts') ->classes() ->toBeInterfaces(); + +arch('class implements contract') + ->expect('HamroCDN\HamroCDN') + ->toImplement('HamroCDN\Contracts\HamroCDNContract'); From f25208d9d8d60ae6119be0dad7ef2dbb41e2d76b Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Mon, 27 Oct 2025 22:51:23 +0545 Subject: [PATCH 05/34] feat: adds index and fetch methods to HamroCDNContract interface Enhances HamroCDN functionality by implementing methods for listing and retrieving files. --- src/Contracts/HamroCDNContract.php | 27 ++++++++++++++++-------- src/HamroCDN.php | 34 ++++++++++++++++++------------ 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/src/Contracts/HamroCDNContract.php b/src/Contracts/HamroCDNContract.php index 1be69a3..f60697c 100644 --- a/src/Contracts/HamroCDNContract.php +++ b/src/Contracts/HamroCDNContract.php @@ -4,29 +4,38 @@ namespace HamroCDN\Contracts; -use HamroCDN\HamroCDN; - /** - * @phpstan-import-type HamroCDNObject from HamroCDN + * @phpstan-type HamroCDNObject array{ + * nanoId: string, + * } */ interface HamroCDNContract { /** - * Upload a file to HamroCDN. + * List all of your files in HamroCDN. * - * @return HamroCDNObject + * @return HamroCDNObject[] */ - public function upload(string $filePath): array; + public function index(): array; /** * Fetch a file from HamroCDN. * * @return HamroCDNObject */ - public function fetch(string $id): array; + public function fetch(string $nanoId): array; /** - * Delete a file from HamroCDN. + * Upload a file to HamroCDN. + * + * @return HamroCDNObject + */ + public function upload(string $filePath): array; + + /** + * Upload a file to HamroCDN by URL. + * + * @return HamroCDNObject */ - public function delete(string $id): bool; + public function uploadByURL(string $url): array; } diff --git a/src/HamroCDN.php b/src/HamroCDN.php index 9ff2a9a..213de4f 100644 --- a/src/HamroCDN.php +++ b/src/HamroCDN.php @@ -6,32 +6,38 @@ use HamroCDN\Contracts\HamroCDNContract; -/** - * @phpstan-type HamroCDNObject array{ - * id: string, - * nanoId: string, - * } - */ final class HamroCDN implements HamroCDNContract { - public function upload(string $filePath): array + public function index(): array { return [ - 'id' => 'sample_id', - 'nanoId' => 'sample_nano_id', + [ + 'nanoId' => 'abcde12345', + ], + [ + 'nanoId' => 'fghij67890', + ], ]; } - public function fetch(string $id): array + public function fetch(string $nanoId): array { return [ - 'id' => 'sample_id', - 'nanoId' => 'sample_nano_id', + 'nanoId' => $nanoId, ]; } - public function delete(string $id): bool + public function upload(string $filePath): array { - return true; + return [ + 'nanoId' => 'newfile12345', + ]; + } + + public function uploadByURL(string $url): array + { + return [ + 'nanoId' => 'urlfile67890', + ]; } } From b146bfe8932bbbd94957559c85ea923f52fb375e Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Mon, 27 Oct 2025 22:55:39 +0545 Subject: [PATCH 06/34] feat: adds PHPStan type definitions for HamroCDNFile and HamroCDNUser --- src/Contracts/HamroCDNContract.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Contracts/HamroCDNContract.php b/src/Contracts/HamroCDNContract.php index f60697c..49ffb84 100644 --- a/src/Contracts/HamroCDNContract.php +++ b/src/Contracts/HamroCDNContract.php @@ -5,8 +5,18 @@ namespace HamroCDN\Contracts; /** + * @phpstan-type HamroCDNFile array{ + * url: string, + * size: int + * } + * + * @phpstan-type HamroCDNUser bool + * * @phpstan-type HamroCDNObject array{ * nanoId: string, + * user: HamroCDNUser, + * delete_at: string|null, + * original: HamroCDNFile * } */ interface HamroCDNContract From 79c220caa28a0a6ded99219e5acdac173e54ffd7 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Mon, 27 Oct 2025 22:57:28 +0545 Subject: [PATCH 07/34] feat: adds GuzzleHttp as a dependency in composer.json --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c5dbc47..60d50c6 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,8 @@ } ], "require": { - "php": ">=8.0" + "php": ">=8.0", + "guzzlehttp/guzzle": "^7.10" }, "require-dev": { "laravel/pint": "^1.25", From 08497b419d8deab09012ebda9c66b6eca5c55a6f Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Mon, 27 Oct 2025 23:01:12 +0545 Subject: [PATCH 08/34] feat: adds constructor to HamroCDN for API key and base URL configuration Includes GuzzleHttp client initialization with default settings. --- src/Contracts/HamroCDNContract.php | 2 -- src/HamroCDN.php | 31 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/Contracts/HamroCDNContract.php b/src/Contracts/HamroCDNContract.php index 49ffb84..427e5ba 100644 --- a/src/Contracts/HamroCDNContract.php +++ b/src/Contracts/HamroCDNContract.php @@ -9,9 +9,7 @@ * url: string, * size: int * } - * * @phpstan-type HamroCDNUser bool - * * @phpstan-type HamroCDNObject array{ * nanoId: string, * user: HamroCDNUser, diff --git a/src/HamroCDN.php b/src/HamroCDN.php index 213de4f..9cd3b49 100644 --- a/src/HamroCDN.php +++ b/src/HamroCDN.php @@ -4,10 +4,41 @@ namespace HamroCDN; +use GuzzleHttp\Client; use HamroCDN\Contracts\HamroCDNContract; final class HamroCDN implements HamroCDNContract { + private Client $client; + + private string $apiKey; + + private string $baseUrl; + + public function __construct(?string $apiKey = null, ?string $baseUrl = null, ?Client $client = null) + { + $this->apiKey = $apiKey + ?? getenv('HAMROCDN_API_KEY') + ?? (function_exists('config') ? (config('hamrocdn.api_key') ?? '') : ''); + + $this->baseUrl = rtrim( + $baseUrl + ?? getenv('HAMROCDN_API_URL') + ?? (function_exists('config') ? (config('hamrocdn.api_url') ?? 'https://hamrocdn.com/api') : 'https://hamrocdn.com/api'), + '/' + ); + + $this->client = $client ?? new Client([ + 'base_uri' => "{$this->baseUrl}/", + 'timeout' => 15, + 'verify' => true, + 'headers' => [ + 'X-API-KEY' => $this->apiKey, + 'Accept' => 'application/json', + ], + ]); + } + public function index(): array { return [ From ff584f4d8f94c3173f3b71dbb95c4035905414a7 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Mon, 27 Oct 2025 23:09:32 +0545 Subject: [PATCH 09/34] feat: adds Requestable trait for API request handling Implements methods for GET and POST requests with JSON response decoding. --- src/HamroCDN.php | 4 +-- src/Traits/Requestable.php | 57 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 src/Traits/Requestable.php diff --git a/src/HamroCDN.php b/src/HamroCDN.php index 9cd3b49..f67cf87 100644 --- a/src/HamroCDN.php +++ b/src/HamroCDN.php @@ -6,11 +6,11 @@ use GuzzleHttp\Client; use HamroCDN\Contracts\HamroCDNContract; +use HamroCDN\Traits\Requestable; final class HamroCDN implements HamroCDNContract { - private Client $client; - + use Requestable; private string $apiKey; private string $baseUrl; diff --git a/src/Traits/Requestable.php b/src/Traits/Requestable.php new file mode 100644 index 0000000..43b93c4 --- /dev/null +++ b/src/Traits/Requestable.php @@ -0,0 +1,57 @@ + $query + * + * @throws RuntimeException|GuzzleException + */ + private function get(string $endpoint, array $query = []): array + { + $response = $this->client->get($endpoint, ['query' => $query]); + + return $this->decodeResponse($response->getBody()->getContents()); + } + + /** + * @template T + * + * @param array $options + * + * @throws RuntimeException|GuzzleException + */ + private function post(string $endpoint, array $options = []): array + { + $response = $this->client->post($endpoint, $options); + + return $this->decodeResponse($response->getBody()->getContents()); + } + + /** + * @return array + * + * @throws RuntimeException + */ + private function decodeResponse(string $json): array + { + $decoded = json_decode($json, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException('Invalid JSON returned by API.'); + } + + return $decoded; + } +} From 616c6bddecb277b58f9509707b0fc677c9aef983 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Mon, 27 Oct 2025 23:17:21 +0545 Subject: [PATCH 10/34] feat: adds HasConfigValues trait for API key and base URL resolution --- src/Traits/HasConfigValues.php | 50 ++++++++++++++++++++++++++++++++++ src/Traits/Requestable.php | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/Traits/HasConfigValues.php diff --git a/src/Traits/HasConfigValues.php b/src/Traits/HasConfigValues.php new file mode 100644 index 0000000..91973d2 --- /dev/null +++ b/src/Traits/HasConfigValues.php @@ -0,0 +1,50 @@ +apiKey = $apiKey + ?? $this->getConfigValue('hamrocdn.api_key') + ?? getenv('HAMROCDN_API_KEY'); + + $this->baseUrl = rtrim( + $baseUrl + ?? $this->getConfigValue('hamrocdn.api_url') + ?? getenv('HAMROCDN_API_URL') + ?? $defaultURL, + '/' + ); + + if (empty($this->apiKey)) { + throw new RuntimeException('HamroCDN API key is missing.'); + } + + return [$this->apiKey, $this->baseUrl]; + } + + /** + * Retrieve a configuration value if the helper exists. + */ + private function getConfigValue(string $key): ?string + { + return function_exists('config') ? config($key) : null; + } +} diff --git a/src/Traits/Requestable.php b/src/Traits/Requestable.php index 43b93c4..ce8f1ce 100644 --- a/src/Traits/Requestable.php +++ b/src/Traits/Requestable.php @@ -10,7 +10,7 @@ trait Requestable { - private Client $client; + protected Client $client; /** * @template T From e38d0d9ce06694b968a2faa96440c27b6be45096 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Mon, 27 Oct 2025 23:18:13 +0545 Subject: [PATCH 11/34] feat: refactors HamroCDN to use HasConfigValues for configuration resolution This change integrates the HasConfigValues trait into the HamroCDN class, streamlining the API key and base URL resolution process. --- src/HamroCDN.php | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/HamroCDN.php b/src/HamroCDN.php index f67cf87..d41050d 100644 --- a/src/HamroCDN.php +++ b/src/HamroCDN.php @@ -6,27 +6,16 @@ use GuzzleHttp\Client; use HamroCDN\Contracts\HamroCDNContract; +use HamroCDN\Traits\HasConfigValues; use HamroCDN\Traits\Requestable; final class HamroCDN implements HamroCDNContract { - use Requestable; - private string $apiKey; - - private string $baseUrl; + use HasConfigValues, Requestable; public function __construct(?string $apiKey = null, ?string $baseUrl = null, ?Client $client = null) { - $this->apiKey = $apiKey - ?? getenv('HAMROCDN_API_KEY') - ?? (function_exists('config') ? (config('hamrocdn.api_key') ?? '') : ''); - - $this->baseUrl = rtrim( - $baseUrl - ?? getenv('HAMROCDN_API_URL') - ?? (function_exists('config') ? (config('hamrocdn.api_url') ?? 'https://hamrocdn.com/api') : 'https://hamrocdn.com/api'), - '/' - ); + [$this->apiKey, $this->baseUrl] = $this->resolveConfig($apiKey, $baseUrl); $this->client = $client ?? new Client([ 'base_uri' => "{$this->baseUrl}/", From 93486369397b4db59aeca1551337aa10b998719c Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Mon, 27 Oct 2025 23:27:26 +0545 Subject: [PATCH 12/34] feat: enhances mock data structure with user and original file details Adds user status, deletion timestamp, and original file information to the data structure returned by HamroCDN methods for improved file management and clarity. --- src/HamroCDN.php | 30 ++++++++++++++++++++++++++++++ src/Traits/HasConfigValues.php | 26 ++++++++++++++++++++------ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/HamroCDN.php b/src/HamroCDN.php index d41050d..15c497a 100644 --- a/src/HamroCDN.php +++ b/src/HamroCDN.php @@ -33,9 +33,21 @@ public function index(): array return [ [ 'nanoId' => 'abcde12345', + 'user' => true, + 'delete_at' => null, + 'original' => [ + 'url' => 'https://hamrocdn.com/files/abcde12345/original.jpg', + 'size' => 204800, + ], ], [ 'nanoId' => 'fghij67890', + 'user' => false, + 'delete_at' => null, + 'original' => [ + 'url' => 'https://hamrocdn.com/files/fghij67890/original.jpg', + 'size' => 204800, + ], ], ]; } @@ -44,6 +56,12 @@ public function fetch(string $nanoId): array { return [ 'nanoId' => $nanoId, + 'user' => false, + 'delete_at' => null, + 'original' => [ + 'url' => "https://hamrocdn.com/files/{$nanoId}/original.jpg", + 'size' => 204800, + ], ]; } @@ -51,6 +69,12 @@ public function upload(string $filePath): array { return [ 'nanoId' => 'newfile12345', + 'user' => false, + 'delete_at' => null, + 'original' => [ + 'url' => 'https://hamrocdn.com/files/newfile12345/original.jpg', + 'size' => 204800, + ], ]; } @@ -58,6 +82,12 @@ public function uploadByURL(string $url): array { return [ 'nanoId' => 'urlfile67890', + 'user' => false, + 'delete_at' => null, + 'original' => [ + 'url' => 'https://hamrocdn.com/files/urlfile67890/original.jpg', + 'size' => 204800, + ], ]; } } diff --git a/src/Traits/HasConfigValues.php b/src/Traits/HasConfigValues.php index 91973d2..b7b14fd 100644 --- a/src/Traits/HasConfigValues.php +++ b/src/Traits/HasConfigValues.php @@ -21,14 +21,14 @@ protected function resolveConfig(?string $apiKey = null, ?string $baseUrl = null { $defaultURL = 'https://hamrocdn.com/api'; - $this->apiKey = $apiKey - ?? $this->getConfigValue('hamrocdn.api_key') - ?? getenv('HAMROCDN_API_KEY'); + $this->apiKey = $this->isEmpty($apiKey) + ?? $this->isEmpty($this->getConfigValue('hamrocdn.api_key')) + ?? $this->isEmpty(getenv('HAMROCDN_API_KEY')); $this->baseUrl = rtrim( - $baseUrl - ?? $this->getConfigValue('hamrocdn.api_url') - ?? getenv('HAMROCDN_API_URL') + $this->isEmpty($baseUrl) + ?? $this->isEmpty($this->getConfigValue('hamrocdn.api_url')) + ?? $this->isEmpty(getenv('HAMROCDN_API_URL')) ?? $defaultURL, '/' ); @@ -40,6 +40,20 @@ protected function resolveConfig(?string $apiKey = null, ?string $baseUrl = null return [$this->apiKey, $this->baseUrl]; } + /** + * @template T + * + * Check if the value is empty or not. + * In return, it returns the value if not empty and false otherwise. + * + * @param T $value + * @return T|bool + */ + private function isEmpty(mixed $value): mixed + { + return empty($value) ? false : $value; + } + /** * Retrieve a configuration value if the helper exists. */ From 24a7a46d60df0c4f47fb64b6af0f5fe2fc655910 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Mon, 27 Oct 2025 23:48:41 +0545 Subject: [PATCH 13/34] refactor: improves configuration resolution with PHPStan --- src/Contracts/HamroCDNContract.php | 1 + src/Traits/HasConfigValues.php | 48 +++++++++++++++++------------- src/Traits/Requestable.php | 13 ++++---- 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/src/Contracts/HamroCDNContract.php b/src/Contracts/HamroCDNContract.php index 427e5ba..aff2e87 100644 --- a/src/Contracts/HamroCDNContract.php +++ b/src/Contracts/HamroCDNContract.php @@ -10,6 +10,7 @@ * size: int * } * @phpstan-type HamroCDNUser bool + * * @phpstan-type HamroCDNObject array{ * nanoId: string, * user: HamroCDNUser, diff --git a/src/Traits/HasConfigValues.php b/src/Traits/HasConfigValues.php index b7b14fd..19351ec 100644 --- a/src/Traits/HasConfigValues.php +++ b/src/Traits/HasConfigValues.php @@ -8,57 +8,63 @@ trait HasConfigValues { - protected string $apiKey; + protected ?string $apiKey; protected string $baseUrl; /** * Resolve API key and base URL from multiple sources. * - * @return array{string,string} [apiKey, baseUrl] + * @return array{string, string} [apiKey, baseUrl] */ protected function resolveConfig(?string $apiKey = null, ?string $baseUrl = null): array { $defaultURL = 'https://hamrocdn.com/api'; - $this->apiKey = $this->isEmpty($apiKey) - ?? $this->isEmpty($this->getConfigValue('hamrocdn.api_key')) - ?? $this->isEmpty(getenv('HAMROCDN_API_KEY')); + $this->apiKey = $this->stringOrNull($apiKey) + ?? $this->getConfigValue('hamrocdn.api_key') + ?? $this->getEnvValue('HAMROCDN_API_KEY'); $this->baseUrl = rtrim( - $this->isEmpty($baseUrl) - ?? $this->isEmpty($this->getConfigValue('hamrocdn.api_url')) - ?? $this->isEmpty(getenv('HAMROCDN_API_URL')) + $baseUrl + ?? $this->getConfigValue('hamrocdn.api_url') + ?? $this->getEnvValue('HAMROCDN_API_URL') ?? $defaultURL, '/' ); if (empty($this->apiKey)) { - throw new RuntimeException('HamroCDN API key is missing.'); + throw new RuntimeException('API key is required for HamroCDN client.'); } return [$this->apiKey, $this->baseUrl]; } /** - * @template T - * - * Check if the value is empty or not. - * In return, it returns the value if not empty and false otherwise. - * - * @param T $value - * @return T|bool + * Retrieve a configuration value if the helper exists. */ - private function isEmpty(mixed $value): mixed + private function getConfigValue(string $key): ?string { - return empty($value) ? false : $value; + return $this->stringOrNull( + function_exists('config') ? config($key) : null + ); } /** - * Retrieve a configuration value if the helper exists. + * Retrieve a environment variable if the helper exists. */ - private function getConfigValue(string $key): ?string + private function getEnvValue(string $key): ?string + { + return $this->stringOrNull( + function_exists('env') ? getenv($key) : null + ); + } + + /** + * Convert false|string|null into nullable string. + */ + private function stringOrNull(string|false|null $value): ?string { - return function_exists('config') ? config($key) : null; + return is_string($value) && $value !== '' ? $value : null; } } diff --git a/src/Traits/Requestable.php b/src/Traits/Requestable.php index ce8f1ce..7142ca0 100644 --- a/src/Traits/Requestable.php +++ b/src/Traits/Requestable.php @@ -6,17 +6,17 @@ use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; +use HamroCDN\Contracts\HamroCDNContract; use RuntimeException; +/** @phpstan-import-type HamroCDNObject from HamroCDNContract */ trait Requestable { protected Client $client; /** - * @template T - * * @param array $query - * + * @return HamroCDNObject * @throws RuntimeException|GuzzleException */ private function get(string $endpoint, array $query = []): array @@ -27,10 +27,8 @@ private function get(string $endpoint, array $query = []): array } /** - * @template T - * * @param array $options - * + * @return HamroCDNObject * @throws RuntimeException|GuzzleException */ private function post(string $endpoint, array $options = []): array @@ -41,12 +39,13 @@ private function post(string $endpoint, array $options = []): array } /** - * @return array + * @return HamroCDNObject * * @throws RuntimeException */ private function decodeResponse(string $json): array { + /** @var HamroCDNObject $decoded */ $decoded = json_decode($json, true); if (json_last_error() !== JSON_ERROR_NONE) { throw new RuntimeException('Invalid JSON returned by API.'); From df6cb8c37a75dd73f334e026377a8822fdc29b0f Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 00:18:07 +0545 Subject: [PATCH 14/34] refactor: phpstan issues fixed --- src/Traits/HasConfigValues.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Traits/HasConfigValues.php b/src/Traits/HasConfigValues.php index 19351ec..a9e1d08 100644 --- a/src/Traits/HasConfigValues.php +++ b/src/Traits/HasConfigValues.php @@ -45,9 +45,8 @@ protected function resolveConfig(?string $apiKey = null, ?string $baseUrl = null */ private function getConfigValue(string $key): ?string { - return $this->stringOrNull( - function_exists('config') ? config($key) : null - ); + /** @var string|null */ + return function_exists('config') ? config($key) : null; } /** From 9d723d2ba1579b11dc76bb3ea13ba2f3ae54c9b1 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 00:21:05 +0545 Subject: [PATCH 15/34] refactor: removes unnecessary comments and improves code clarity --- rector.php | 4 ++++ src/Contracts/HamroCDNContract.php | 1 - src/Traits/Requestable.php | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/rector.php b/rector.php index 477eaf1..7bc94c1 100644 --- a/rector.php +++ b/rector.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Rector\Config\RectorConfig; +use Rector\DeadCode\Rector\Node\RemoveNonExistingVarAnnotationRector; try { return RectorConfig::configure() @@ -16,6 +17,9 @@ privatization: true, earlyReturn: true, ) + ->withSkip([ + RemoveNonExistingVarAnnotationRector::class, + ]) ->withPhpSets( php84: true, ); diff --git a/src/Contracts/HamroCDNContract.php b/src/Contracts/HamroCDNContract.php index aff2e87..427e5ba 100644 --- a/src/Contracts/HamroCDNContract.php +++ b/src/Contracts/HamroCDNContract.php @@ -10,7 +10,6 @@ * size: int * } * @phpstan-type HamroCDNUser bool - * * @phpstan-type HamroCDNObject array{ * nanoId: string, * user: HamroCDNUser, diff --git a/src/Traits/Requestable.php b/src/Traits/Requestable.php index 7142ca0..e3abbaa 100644 --- a/src/Traits/Requestable.php +++ b/src/Traits/Requestable.php @@ -17,6 +17,7 @@ trait Requestable /** * @param array $query * @return HamroCDNObject + * * @throws RuntimeException|GuzzleException */ private function get(string $endpoint, array $query = []): array @@ -29,6 +30,7 @@ private function get(string $endpoint, array $query = []): array /** * @param array $options * @return HamroCDNObject + * * @throws RuntimeException|GuzzleException */ private function post(string $endpoint, array $options = []): array From 01aca12512dccae489612fee518658f3d3dc6e90 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 01:21:08 +0545 Subject: [PATCH 16/34] feat: updates index method to return uploads data structure Enhances the HamroCDN index method to fetch uploads instead of hardcoded values. Adds tests to validate the structure of the returned data. --- src/HamroCDN.php | 21 +-------------------- tests/Pest.php | 42 ------------------------------------------ tests/TestCase.php | 7 ------- tests/UploadTest.php | 29 +++++++++++++++++++++++++++-- 4 files changed, 28 insertions(+), 71 deletions(-) delete mode 100644 tests/Pest.php delete mode 100644 tests/TestCase.php diff --git a/src/HamroCDN.php b/src/HamroCDN.php index 15c497a..94cb133 100644 --- a/src/HamroCDN.php +++ b/src/HamroCDN.php @@ -30,26 +30,7 @@ public function __construct(?string $apiKey = null, ?string $baseUrl = null, ?Cl public function index(): array { - return [ - [ - 'nanoId' => 'abcde12345', - 'user' => true, - 'delete_at' => null, - 'original' => [ - 'url' => 'https://hamrocdn.com/files/abcde12345/original.jpg', - 'size' => 204800, - ], - ], - [ - 'nanoId' => 'fghij67890', - 'user' => false, - 'delete_at' => null, - 'original' => [ - 'url' => 'https://hamrocdn.com/files/fghij67890/original.jpg', - 'size' => 204800, - ], - ], - ]; + return $this->get('uploads'); } public function fetch(string $nanoId): array diff --git a/tests/Pest.php b/tests/Pest.php deleted file mode 100644 index 3a88a3b..0000000 --- a/tests/Pest.php +++ /dev/null @@ -1,42 +0,0 @@ -extend(HamroCDN\Tests\TestCase::class)->in('Unit', 'Feature'); - -/* -|-------------------------------------------------------------------------- -| Expectations -|-------------------------------------------------------------------------- -| -| When you're writing tests, you often need to check that values meet certain conditions. The -| "expect()" function gives you access to a set of "expectations" methods that you can use -| to assert different things. Of course, you may extend the Expectation API at any time. -| -*/ - -expect()->extend('toBeOne', function () { - return $this->toBe(1); -}); - -/* -|-------------------------------------------------------------------------- -| Functions -|-------------------------------------------------------------------------- -| -| While Pest is very powerful out-of-the-box, you may have some testing code specific to your -| project that you don't want to repeat in every file. Here you can also expose helpers as -| global functions to help you to reduce the number of lines of code in your test files. -| -*/ diff --git a/tests/TestCase.php b/tests/TestCase.php deleted file mode 100644 index 77253f7..0000000 --- a/tests/TestCase.php +++ /dev/null @@ -1,7 +0,0 @@ -toBe(1); +use HamroCDN\HamroCDN; +it('returns an array of HamroCDN objects from index', function () { + $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); + + $uploads = $client->index(); + + expect($uploads)->toBeArray(); + expect($uploads) + ->toHaveKey('data') + ->toHaveKey('meta'); + + foreach ($uploads['data'] as $upload) { + expect($upload) + ->toHaveKey('nanoId') + ->toHaveKey('user') + ->toHaveKey('delete_at') + ->toHaveKey('original'); + + expect($upload['original']) + ->toHaveKey('url') + ->toHaveKey('size'); + } + + expect($uploads['meta']) + ->toHaveKey('total') + ->toHaveKey('per_page') + ->toHaveKey('page'); }); From 16a9dac7e47082c6ccff902be689259db89f3e80 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 01:35:40 +0545 Subject: [PATCH 17/34] refactor: updates type hints and PHPStan annotations --- src/Contracts/HamroCDNContract.php | 7 ++++++- src/HamroCDN.php | 13 +++++++++++++ src/Traits/Requestable.php | 13 ++++++++----- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/Contracts/HamroCDNContract.php b/src/Contracts/HamroCDNContract.php index 427e5ba..b27de7b 100644 --- a/src/Contracts/HamroCDNContract.php +++ b/src/Contracts/HamroCDNContract.php @@ -16,13 +16,18 @@ * delete_at: string|null, * original: HamroCDNFile * } + * + * @phpstan-type HamroCDNObjectWithPagination array{ + * data: HamroCDNObject[], + * meta: array{total: int, per_page: int, page: int} + * } */ interface HamroCDNContract { /** * List all of your files in HamroCDN. * - * @return HamroCDNObject[] + * @return HamroCDNObjectWithPagination */ public function index(): array; diff --git a/src/HamroCDN.php b/src/HamroCDN.php index 94cb133..e0edb49 100644 --- a/src/HamroCDN.php +++ b/src/HamroCDN.php @@ -5,12 +5,20 @@ namespace HamroCDN; use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; use HamroCDN\Contracts\HamroCDNContract; use HamroCDN\Traits\HasConfigValues; use HamroCDN\Traits\Requestable; +/** + * @phpstan-import-type HamroCDNObject from HamroCDNContract + * @phpstan-import-type HamroCDNObjectWithPagination from HamroCDNContract + */ final class HamroCDN implements HamroCDNContract { + /** + * @use Requestable + */ use HasConfigValues, Requestable; public function __construct(?string $apiKey = null, ?string $baseUrl = null, ?Client $client = null) @@ -28,8 +36,13 @@ public function __construct(?string $apiKey = null, ?string $baseUrl = null, ?Cl ]); } + /** + * @return HamroCDNObjectWithPagination + * @throws GuzzleException + */ public function index(): array { + /** @var HamroCDNObjectWithPagination */ return $this->get('uploads'); } diff --git a/src/Traits/Requestable.php b/src/Traits/Requestable.php index e3abbaa..cf76778 100644 --- a/src/Traits/Requestable.php +++ b/src/Traits/Requestable.php @@ -9,14 +9,17 @@ use HamroCDN\Contracts\HamroCDNContract; use RuntimeException; -/** @phpstan-import-type HamroCDNObject from HamroCDNContract */ +/** + * @phpstan-import-type HamroCDNObject from HamroCDNContract + * @template T + */ trait Requestable { protected Client $client; /** * @param array $query - * @return HamroCDNObject + * @return T * * @throws RuntimeException|GuzzleException */ @@ -29,7 +32,7 @@ private function get(string $endpoint, array $query = []): array /** * @param array $options - * @return HamroCDNObject + * @return T * * @throws RuntimeException|GuzzleException */ @@ -41,13 +44,13 @@ private function post(string $endpoint, array $options = []): array } /** - * @return HamroCDNObject + * @return T * * @throws RuntimeException */ private function decodeResponse(string $json): array { - /** @var HamroCDNObject $decoded */ + /** @var T $decoded */ $decoded = json_decode($json, true); if (json_last_error() !== JSON_ERROR_NONE) { throw new RuntimeException('Invalid JSON returned by API.'); From 9764f5667f0ebffe519411d191d23bff2a265acc Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 01:50:17 +0545 Subject: [PATCH 18/34] feat: adds file upload functionality with error handling --- src/HamroCDN.php | 22 ++++++++++++++-------- tests/UploadTest.php | 15 +++++++++++++++ tests/test.png | Bin 0 -> 46549 bytes 3 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 tests/test.png diff --git a/src/HamroCDN.php b/src/HamroCDN.php index e0edb49..d31a4cc 100644 --- a/src/HamroCDN.php +++ b/src/HamroCDN.php @@ -9,6 +9,7 @@ use HamroCDN\Contracts\HamroCDNContract; use HamroCDN\Traits\HasConfigValues; use HamroCDN\Traits\Requestable; +use RuntimeException; /** * @phpstan-import-type HamroCDNObject from HamroCDNContract @@ -38,6 +39,7 @@ public function __construct(?string $apiKey = null, ?string $baseUrl = null, ?Cl /** * @return HamroCDNObjectWithPagination + * * @throws GuzzleException */ public function index(): array @@ -61,15 +63,19 @@ public function fetch(string $nanoId): array public function upload(string $filePath): array { - return [ - 'nanoId' => 'newfile12345', - 'user' => false, - 'delete_at' => null, - 'original' => [ - 'url' => 'https://hamrocdn.com/files/newfile12345/original.jpg', - 'size' => 204800, + if (! file_exists($filePath)) { + throw new RuntimeException("File not found: {$filePath}"); + } + + return $this->post('uploads', [ + 'multipart' => [ + [ + 'name' => 'file', + 'contents' => fopen($filePath, 'r'), + 'filename' => basename($filePath), + ], ], - ]; + ]); } public function uploadByURL(string $url): array diff --git a/tests/UploadTest.php b/tests/UploadTest.php index f182ec5..b144ebc 100644 --- a/tests/UploadTest.php +++ b/tests/UploadTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use HamroCDN\HamroCDN; + it('returns an array of HamroCDN objects from index', function () { $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); @@ -30,3 +31,17 @@ ->toHaveKey('per_page') ->toHaveKey('page'); }); + +it('uploads a file and returns a HamroCDN object', function () { + $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); + + $filePath = __DIR__.'/test.png'; + $upload = $client->upload($filePath); + $data = $upload['data']; + + expect($data) + ->toHaveKey('nanoId') + ->toHaveKey('user') + ->toHaveKey('delete_at') + ->toHaveKey('original'); +}); diff --git a/tests/test.png b/tests/test.png new file mode 100644 index 0000000000000000000000000000000000000000..32dcb26257a231c4867c182440a8390173203b5d GIT binary patch literal 46549 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 aDsneDMvcjGmg{0LIjk-0%&(YjPy8R=)4@If literal 0 HcmV?d00001 From df8a55b5a2b3802712f8f59dcd099bb9b86fb2b2 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 01:54:02 +0545 Subject: [PATCH 19/34] feat: updates index method to support pagination parameters Enhances the index method to accept optional pagination parameters for improved data retrieval. --- src/HamroCDN.php | 7 +++++-- src/Traits/Requestable.php | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/HamroCDN.php b/src/HamroCDN.php index d31a4cc..79fcb2a 100644 --- a/src/HamroCDN.php +++ b/src/HamroCDN.php @@ -42,10 +42,13 @@ public function __construct(?string $apiKey = null, ?string $baseUrl = null, ?Cl * * @throws GuzzleException */ - public function index(): array + public function index(?int $per_page = 20, ?int $page = 1): array { /** @var HamroCDNObjectWithPagination */ - return $this->get('uploads'); + return $this->get('uploads', [ + 'per_page' => $per_page, + 'page' => $page, + ]); } public function fetch(string $nanoId): array diff --git a/src/Traits/Requestable.php b/src/Traits/Requestable.php index cf76778..108b7f5 100644 --- a/src/Traits/Requestable.php +++ b/src/Traits/Requestable.php @@ -51,7 +51,7 @@ private function post(string $endpoint, array $options = []): array private function decodeResponse(string $json): array { /** @var T $decoded */ - $decoded = json_decode($json, true); + $decoded = json_decode($json, true)['data']; if (json_last_error() !== JSON_ERROR_NONE) { throw new RuntimeException('Invalid JSON returned by API.'); } From dd71b20834d90e449d222cb550b810825045caf7 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 01:55:23 +0545 Subject: [PATCH 20/34] refactor: updates JSON decoding to return full response structure --- src/Traits/Requestable.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Traits/Requestable.php b/src/Traits/Requestable.php index 108b7f5..cf76778 100644 --- a/src/Traits/Requestable.php +++ b/src/Traits/Requestable.php @@ -51,7 +51,7 @@ private function post(string $endpoint, array $options = []): array private function decodeResponse(string $json): array { /** @var T $decoded */ - $decoded = json_decode($json, true)['data']; + $decoded = json_decode($json, true); if (json_last_error() !== JSON_ERROR_NONE) { throw new RuntimeException('Invalid JSON returned by API.'); } From 7f6fce82ebcbb68bd497e0face316bd1859d99af Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 02:05:44 +0545 Subject: [PATCH 21/34] refactor: updates HamroCDNUser type definition to include user details --- src/Contracts/HamroCDNContract.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Contracts/HamroCDNContract.php b/src/Contracts/HamroCDNContract.php index b27de7b..1492990 100644 --- a/src/Contracts/HamroCDNContract.php +++ b/src/Contracts/HamroCDNContract.php @@ -9,7 +9,11 @@ * url: string, * size: int * } - * @phpstan-type HamroCDNUser bool + * @phpstan-type HamroCDNUser array{ + * name: string, + * email: string, + * role: string + * } * @phpstan-type HamroCDNObject array{ * nanoId: string, * user: HamroCDNUser, From 60dffb56c0ede040371971af0a414ff32d44fd8f Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 02:09:03 +0545 Subject: [PATCH 22/34] refactor: updates fetch method to use API call for data retrieval --- src/HamroCDN.php | 10 +--------- tests/UploadTest.php | 12 ++++++++++++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/HamroCDN.php b/src/HamroCDN.php index 79fcb2a..007d2f2 100644 --- a/src/HamroCDN.php +++ b/src/HamroCDN.php @@ -53,15 +53,7 @@ public function index(?int $per_page = 20, ?int $page = 1): array public function fetch(string $nanoId): array { - return [ - 'nanoId' => $nanoId, - 'user' => false, - 'delete_at' => null, - 'original' => [ - 'url' => "https://hamrocdn.com/files/{$nanoId}/original.jpg", - 'size' => 204800, - ], - ]; + return $this->get("uploads/{$nanoId}"); } public function upload(string $filePath): array diff --git a/tests/UploadTest.php b/tests/UploadTest.php index b144ebc..dc06d10 100644 --- a/tests/UploadTest.php +++ b/tests/UploadTest.php @@ -44,4 +44,16 @@ ->toHaveKey('user') ->toHaveKey('delete_at') ->toHaveKey('original'); + + $fetchResponse = $client->fetch($data['nanoId']); + var_dump($fetchResponse); + $fetchedData = $fetchResponse['data']; + + expect($fetchedData) + ->toHaveKey('nanoId') + ->toHaveKey('user') + ->toHaveKey('delete_at') + ->toHaveKey('original'); + + expect($fetchedData['nanoId'])->toBe($data['nanoId']); }); From 70e55153f8df588b8289fc870c87a386e87ab199 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 02:13:10 +0545 Subject: [PATCH 23/34] feat: adds uploadByURL method to upload files from a URL Implements functionality to upload files by URL and return HamroCDN object. --- src/HamroCDN.php | 12 ++++-------- tests/UploadTest.php | 27 ++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/HamroCDN.php b/src/HamroCDN.php index 007d2f2..66f3b82 100644 --- a/src/HamroCDN.php +++ b/src/HamroCDN.php @@ -75,14 +75,10 @@ public function upload(string $filePath): array public function uploadByURL(string $url): array { - return [ - 'nanoId' => 'urlfile67890', - 'user' => false, - 'delete_at' => null, - 'original' => [ - 'url' => 'https://hamrocdn.com/files/urlfile67890/original.jpg', - 'size' => 204800, + return $this->post('upload-from-url', [ + 'json' => [ + 'url' => $url, ], - ]; + ]); } } diff --git a/tests/UploadTest.php b/tests/UploadTest.php index dc06d10..0688995 100644 --- a/tests/UploadTest.php +++ b/tests/UploadTest.php @@ -46,7 +46,32 @@ ->toHaveKey('original'); $fetchResponse = $client->fetch($data['nanoId']); - var_dump($fetchResponse); + $fetchedData = $fetchResponse['data']; + + expect($fetchedData) + ->toHaveKey('nanoId') + ->toHaveKey('user') + ->toHaveKey('delete_at') + ->toHaveKey('original'); + + expect($fetchedData['nanoId'])->toBe($data['nanoId']); +}); + +it('uploads a file by URL and returns a HamroCDN object', function () { + $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); + + $fileUrl = 'https://placehold.co/1000x1000/000000/FFFFFF?text=HamroCDN'; + + $upload = $client->uploadByURL($fileUrl); + $data = $upload['data']; + + expect($data) + ->toHaveKey('nanoId') + ->toHaveKey('user') + ->toHaveKey('delete_at') + ->toHaveKey('original'); + + $fetchResponse = $client->fetch($data['nanoId']); $fetchedData = $fetchResponse['data']; expect($fetchedData) From 64929904e4e6960ffdbf00dcb0bb64432cd0640a Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 02:14:49 +0545 Subject: [PATCH 24/34] test: adds test for exception when uploading a non-existing file --- tests/UploadTest.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/UploadTest.php b/tests/UploadTest.php index 0688995..54b334a 100644 --- a/tests/UploadTest.php +++ b/tests/UploadTest.php @@ -57,6 +57,15 @@ expect($fetchedData['nanoId'])->toBe($data['nanoId']); }); +it('throws exception when uploading a non-existing file', function () { + $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); + + $filePath = __DIR__.'/non-existing-file.png'; + + $this->expectException(RuntimeException::class); + $client->upload($filePath); +}); + it('uploads a file by URL and returns a HamroCDN object', function () { $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); From ba238fd81cad5ec50b5b29b62b68841e84f8a62f Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 02:18:16 +0545 Subject: [PATCH 25/34] test: adds tests for exception handling in HamroCDN initialization Includes checks for missing API key and invalid JSON responses. --- tests/UploadTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/UploadTest.php b/tests/UploadTest.php index 54b334a..1d09d5a 100644 --- a/tests/UploadTest.php +++ b/tests/UploadTest.php @@ -4,6 +4,10 @@ use HamroCDN\HamroCDN; +it('throws exception when API key is missing', function () { + $this->expectException(RuntimeException::class); + new HamroCDN(); +}); it('returns an array of HamroCDN objects from index', function () { $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); @@ -91,3 +95,16 @@ expect($fetchedData['nanoId'])->toBe($data['nanoId']); }); + +it('throws exception when returns invalid json', function () { + $mockHandler = new GuzzleHttp\Handler\MockHandler([ + new GuzzleHttp\Psr7\Response(200, [], 'Invalid JSON'), + ]); + $handlerStack = GuzzleHttp\HandlerStack::create($mockHandler); + $guzzleClient = new GuzzleHttp\Client(['handler' => $handlerStack]); + + $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api', $guzzleClient); + + $this->expectException(RuntimeException::class); + $client->index(); +}); From 74fdde2eef07e51d2619f20e1e1df899db446e43 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 02:19:47 +0545 Subject: [PATCH 26/34] test: groups exception handling tests --- tests/UploadTest.php | 48 +++++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/tests/UploadTest.php b/tests/UploadTest.php index 1d09d5a..ca2cd50 100644 --- a/tests/UploadTest.php +++ b/tests/UploadTest.php @@ -3,11 +3,6 @@ declare(strict_types=1); use HamroCDN\HamroCDN; - -it('throws exception when API key is missing', function () { - $this->expectException(RuntimeException::class); - new HamroCDN(); -}); it('returns an array of HamroCDN objects from index', function () { $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); @@ -61,15 +56,6 @@ expect($fetchedData['nanoId'])->toBe($data['nanoId']); }); -it('throws exception when uploading a non-existing file', function () { - $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); - - $filePath = __DIR__.'/non-existing-file.png'; - - $this->expectException(RuntimeException::class); - $client->upload($filePath); -}); - it('uploads a file by URL and returns a HamroCDN object', function () { $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); @@ -96,15 +82,31 @@ expect($fetchedData['nanoId'])->toBe($data['nanoId']); }); -it('throws exception when returns invalid json', function () { - $mockHandler = new GuzzleHttp\Handler\MockHandler([ - new GuzzleHttp\Psr7\Response(200, [], 'Invalid JSON'), - ]); - $handlerStack = GuzzleHttp\HandlerStack::create($mockHandler); - $guzzleClient = new GuzzleHttp\Client(['handler' => $handlerStack]); +describe('exception', function () { + it('throws exception when API key is missing', function () { + $this->expectException(RuntimeException::class); + new HamroCDN(); + }); + + it('throws exception when uploading a non-existing file', function () { + $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); + + $filePath = __DIR__.'/non-existing-file.png'; + + $this->expectException(RuntimeException::class); + $client->upload($filePath); + }); + + it('throws exception when returns invalid json', function () { + $mockHandler = new GuzzleHttp\Handler\MockHandler([ + new GuzzleHttp\Psr7\Response(200, [], 'Invalid JSON'), + ]); + $handlerStack = GuzzleHttp\HandlerStack::create($mockHandler); + $guzzleClient = new GuzzleHttp\Client(['handler' => $handlerStack]); - $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api', $guzzleClient); + $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api', $guzzleClient); - $this->expectException(RuntimeException::class); - $client->index(); + $this->expectException(RuntimeException::class); + $client->index(); + }); }); From 816d392f99383b884ec6809494192609264988ba Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 02:22:05 +0545 Subject: [PATCH 27/34] style: removes unnecessary comments --- src/Contracts/HamroCDNContract.php | 1 - src/Traits/Requestable.php | 1 + tests/UploadTest.php | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Contracts/HamroCDNContract.php b/src/Contracts/HamroCDNContract.php index 1492990..a2f671e 100644 --- a/src/Contracts/HamroCDNContract.php +++ b/src/Contracts/HamroCDNContract.php @@ -20,7 +20,6 @@ * delete_at: string|null, * original: HamroCDNFile * } - * * @phpstan-type HamroCDNObjectWithPagination array{ * data: HamroCDNObject[], * meta: array{total: int, per_page: int, page: int} diff --git a/src/Traits/Requestable.php b/src/Traits/Requestable.php index cf76778..e318f48 100644 --- a/src/Traits/Requestable.php +++ b/src/Traits/Requestable.php @@ -11,6 +11,7 @@ /** * @phpstan-import-type HamroCDNObject from HamroCDNContract + * * @template T */ trait Requestable diff --git a/tests/UploadTest.php b/tests/UploadTest.php index ca2cd50..2fef6db 100644 --- a/tests/UploadTest.php +++ b/tests/UploadTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use HamroCDN\HamroCDN; + it('returns an array of HamroCDN objects from index', function () { $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); From dfaf9ba7b196d7fbc70e2ce2590f8c6422db28a9 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 02:31:02 +0545 Subject: [PATCH 28/34] refactor: updates type annotations --- src/Contracts/HamroCDNContract.php | 11 +++++++---- src/HamroCDN.php | 4 ++-- tests/UploadTest.php | 10 +++++----- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/Contracts/HamroCDNContract.php b/src/Contracts/HamroCDNContract.php index a2f671e..53a1917 100644 --- a/src/Contracts/HamroCDNContract.php +++ b/src/Contracts/HamroCDNContract.php @@ -20,8 +20,11 @@ * delete_at: string|null, * original: HamroCDNFile * } + * @phpstan-type HamroCDNData array{ + * data: HamroCDNObject + * } * @phpstan-type HamroCDNObjectWithPagination array{ - * data: HamroCDNObject[], + * data: array, * meta: array{total: int, per_page: int, page: int} * } */ @@ -37,21 +40,21 @@ public function index(): array; /** * Fetch a file from HamroCDN. * - * @return HamroCDNObject + * @return HamroCDNData */ public function fetch(string $nanoId): array; /** * Upload a file to HamroCDN. * - * @return HamroCDNObject + * @return HamroCDNData */ public function upload(string $filePath): array; /** * Upload a file to HamroCDN by URL. * - * @return HamroCDNObject + * @return HamroCDNData */ public function uploadByURL(string $url): array; } diff --git a/src/HamroCDN.php b/src/HamroCDN.php index 66f3b82..680ef5b 100644 --- a/src/HamroCDN.php +++ b/src/HamroCDN.php @@ -12,13 +12,13 @@ use RuntimeException; /** - * @phpstan-import-type HamroCDNObject from HamroCDNContract + * @phpstan-import-type HamroCDNData from HamroCDNContract * @phpstan-import-type HamroCDNObjectWithPagination from HamroCDNContract */ final class HamroCDN implements HamroCDNContract { /** - * @use Requestable + * @use Requestable */ use HasConfigValues, Requestable; diff --git a/tests/UploadTest.php b/tests/UploadTest.php index 2fef6db..e56c3ec 100644 --- a/tests/UploadTest.php +++ b/tests/UploadTest.php @@ -5,7 +5,7 @@ use HamroCDN\HamroCDN; it('returns an array of HamroCDN objects from index', function () { - $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); + $client = new HamroCDN('test-api-key', 'https://hamrocdn.com/api'); $uploads = $client->index(); @@ -33,7 +33,7 @@ }); it('uploads a file and returns a HamroCDN object', function () { - $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); + $client = new HamroCDN('test-api-key', 'https://hamrocdn.com/api'); $filePath = __DIR__.'/test.png'; $upload = $client->upload($filePath); @@ -58,7 +58,7 @@ }); it('uploads a file by URL and returns a HamroCDN object', function () { - $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); + $client = new HamroCDN('test-api-key', 'https://hamrocdn.com/api'); $fileUrl = 'https://placehold.co/1000x1000/000000/FFFFFF?text=HamroCDN'; @@ -90,7 +90,7 @@ }); it('throws exception when uploading a non-existing file', function () { - $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api'); + $client = new HamroCDN('test-api-key', 'https://hamrocdn.com/api'); $filePath = __DIR__.'/non-existing-file.png'; @@ -105,7 +105,7 @@ $handlerStack = GuzzleHttp\HandlerStack::create($mockHandler); $guzzleClient = new GuzzleHttp\Client(['handler' => $handlerStack]); - $client = new HamroCDN('test-api-key', 'https://hamrocdn.test/api', $guzzleClient); + $client = new HamroCDN('test-api-key', 'https://hamrocdn.com/api', $guzzleClient); $this->expectException(RuntimeException::class); $client->index(); From 8dd21a92954decd8453b9608dfbfe94945328784 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 02:34:58 +0545 Subject: [PATCH 29/34] test: adds exception handling for missing API key in HamroCDN --- tests/UploadTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/UploadTest.php b/tests/UploadTest.php index e56c3ec..035177f 100644 --- a/tests/UploadTest.php +++ b/tests/UploadTest.php @@ -86,7 +86,8 @@ describe('exception', function () { it('throws exception when API key is missing', function () { $this->expectException(RuntimeException::class); - new HamroCDN(); + $client = new HamroCDN(); + $client->index(); }); it('throws exception when uploading a non-existing file', function () { From 2d39154b14f42763b8d890fe2c776f9520cd7849 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 13:17:23 +0545 Subject: [PATCH 30/34] refactor: replaces exceptions with custom HamroCDNException --- src/Exceptions/HamroCDNException.php | 58 ++++++++++++++++++++++++++++ src/HamroCDN.php | 16 ++++++-- src/Traits/Requestable.php | 34 ++++++++++------ 3 files changed, 92 insertions(+), 16 deletions(-) create mode 100644 src/Exceptions/HamroCDNException.php diff --git a/src/Exceptions/HamroCDNException.php b/src/Exceptions/HamroCDNException.php new file mode 100644 index 0000000..728597c --- /dev/null +++ b/src/Exceptions/HamroCDNException.php @@ -0,0 +1,58 @@ +get("uploads/{$nanoId}"); } + /** + * @throws HamroCDNException + */ public function upload(string $filePath): array { if (! file_exists($filePath)) { - throw new RuntimeException("File not found: {$filePath}"); + throw HamroCDNException::fileError($filePath); } return $this->post('uploads', [ @@ -73,6 +78,9 @@ public function upload(string $filePath): array ]); } + /** + * @throws HamroCDNException + */ public function uploadByURL(string $url): array { return $this->post('upload-from-url', [ diff --git a/src/Traits/Requestable.php b/src/Traits/Requestable.php index e318f48..8bc5b33 100644 --- a/src/Traits/Requestable.php +++ b/src/Traits/Requestable.php @@ -6,12 +6,10 @@ use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; -use HamroCDN\Contracts\HamroCDNContract; -use RuntimeException; +use HamroCDN\Exceptions\HamroCDNException; +use Throwable; /** - * @phpstan-import-type HamroCDNObject from HamroCDNContract - * * @template T */ trait Requestable @@ -22,39 +20,51 @@ trait Requestable * @param array $query * @return T * - * @throws RuntimeException|GuzzleException + * @throws HamroCDNException */ private function get(string $endpoint, array $query = []): array { - $response = $this->client->get($endpoint, ['query' => $query]); + try { + $response = $this->client->get($endpoint, ['query' => $query]); - return $this->decodeResponse($response->getBody()->getContents()); + return $this->decodeResponse($response->getBody()->getContents()); + } catch (GuzzleException $e) { + throw HamroCDNException::networkError($e); + } catch (Throwable $e) { + throw new HamroCDNException('Unexpected error while performing GET request.', 1000, $e); + } } /** * @param array $options * @return T * - * @throws RuntimeException|GuzzleException + * @throws HamroCDNException */ private function post(string $endpoint, array $options = []): array { - $response = $this->client->post($endpoint, $options); + try { + $response = $this->client->post($endpoint, $options); - return $this->decodeResponse($response->getBody()->getContents()); + return $this->decodeResponse($response->getBody()->getContents()); + } catch (GuzzleException $e) { + throw HamroCDNException::networkError($e); + } catch (Throwable $e) { + throw new HamroCDNException('Unexpected error while performing POST request.', 1000, $e); + } } /** * @return T * - * @throws RuntimeException + * @throws HamroCDNException */ private function decodeResponse(string $json): array { /** @var T $decoded */ $decoded = json_decode($json, true); if (json_last_error() !== JSON_ERROR_NONE) { - throw new RuntimeException('Invalid JSON returned by API.'); + throw HamroCDNException::invalidResponse('Invalid JSON returned by API.'); } return $decoded; From edcc31f03c135106161c509045c513f2641708ca Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 13:19:59 +0545 Subject: [PATCH 31/34] refactor: replaces RuntimeException with HamroCDNException in config handling --- src/Traits/HasConfigValues.php | 6 ++++-- tests/UploadTest.php | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Traits/HasConfigValues.php b/src/Traits/HasConfigValues.php index a9e1d08..a4270cb 100644 --- a/src/Traits/HasConfigValues.php +++ b/src/Traits/HasConfigValues.php @@ -4,7 +4,7 @@ namespace HamroCDN\Traits; -use RuntimeException; +use HamroCDN\Exceptions\HamroCDNException; trait HasConfigValues { @@ -16,6 +16,8 @@ trait HasConfigValues * Resolve API key and base URL from multiple sources. * * @return array{string, string} [apiKey, baseUrl] + * + * @throws HamroCDNException */ protected function resolveConfig(?string $apiKey = null, ?string $baseUrl = null): array { @@ -34,7 +36,7 @@ protected function resolveConfig(?string $apiKey = null, ?string $baseUrl = null ); if (empty($this->apiKey)) { - throw new RuntimeException('API key is required for HamroCDN client.'); + throw new HamroCDNException('API key is required for HamroCDN client.'); } return [$this->apiKey, $this->baseUrl]; diff --git a/tests/UploadTest.php b/tests/UploadTest.php index 035177f..6d3df6e 100644 --- a/tests/UploadTest.php +++ b/tests/UploadTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use HamroCDN\Exceptions\HamroCDNException; use HamroCDN\HamroCDN; it('returns an array of HamroCDN objects from index', function () { @@ -85,7 +86,7 @@ describe('exception', function () { it('throws exception when API key is missing', function () { - $this->expectException(RuntimeException::class); + $this->expectException(HamroCDNException::class); $client = new HamroCDN(); $client->index(); }); @@ -95,7 +96,7 @@ $filePath = __DIR__.'/non-existing-file.png'; - $this->expectException(RuntimeException::class); + $this->expectException(HamroCDNException::class); $client->upload($filePath); }); @@ -108,7 +109,7 @@ $client = new HamroCDN('test-api-key', 'https://hamrocdn.com/api', $guzzleClient); - $this->expectException(RuntimeException::class); + $this->expectException(HamroCDNException::class); $client->index(); }); }); From 59232e774ce0f8e856a99b82c6fc2075c8fcea32 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 14:05:06 +0545 Subject: [PATCH 32/34] refactor: removes unused configuration exception factory method --- src/Exceptions/HamroCDNException.php | 8 ------- tests/UploadTest.php | 33 +++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/Exceptions/HamroCDNException.php b/src/Exceptions/HamroCDNException.php index 728597c..7abce17 100644 --- a/src/Exceptions/HamroCDNException.php +++ b/src/Exceptions/HamroCDNException.php @@ -40,14 +40,6 @@ public static function invalidResponse(?string $response = null): self return new self($msg, 1002); } - /** - * Factory for configuration issues like missing API key. - */ - public static function configuration(string $detail): self - { - return new self("HamroCDN configuration error: {$detail}", 1003); - } - /** * Factory for invalid local file operations. */ diff --git a/tests/UploadTest.php b/tests/UploadTest.php index 6d3df6e..d627cf8 100644 --- a/tests/UploadTest.php +++ b/tests/UploadTest.php @@ -100,7 +100,7 @@ $client->upload($filePath); }); - it('throws exception when returns invalid json', function () { + it('throws exception when returns invalid json (GET)', function () { $mockHandler = new GuzzleHttp\Handler\MockHandler([ new GuzzleHttp\Psr7\Response(200, [], 'Invalid JSON'), ]); @@ -112,4 +112,35 @@ $this->expectException(HamroCDNException::class); $client->index(); }); + + it('throws exception when returns invalid json (POST)', function () { + $mockHandler = new GuzzleHttp\Handler\MockHandler([ + new GuzzleHttp\Psr7\Response(200, [], 'Invalid JSON'), + ]); + $handlerStack = GuzzleHttp\HandlerStack::create($mockHandler); + $guzzleClient = new GuzzleHttp\Client(['handler' => $handlerStack]); + + $client = new HamroCDN('test-api-key', 'https://hamrocdn.com/api', $guzzleClient); + + $filePath = __DIR__.'/test.png'; + + $this->expectException(HamroCDNException::class); + $client->upload($filePath); + }); + + it('throws network error when Guzzle cannot connect to server. (GET)', function () { + $client = new HamroCDN('test-api-key', 'https://hamrocdn123.com/invalid-api'); + + $this->expectException(HamroCDNException::class); + $client->index(); + }); + + it('throws network error when Guzzle cannot connect to server. (POST)', function () { + $client = new HamroCDN('test-api-key', 'https://hamrocdn123.com/invalid-api'); + + $filePath = __DIR__.'/test.png'; + + $this->expectException(HamroCDNException::class); + $client->upload($filePath); + }); }); From f34af3f4cb2664bb0709bc81aac715754add4517 Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 14:15:22 +0545 Subject: [PATCH 33/34] docs: adds badges for linting and quality gate status in README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index ee8cec3..ee5d047 100644 --- a/README.md +++ b/README.md @@ -1 +1,4 @@ # HamroCDN PHP SDK + +[![Lint & Test PR](https://github.com/HamroCDN/php-sdk/actions/workflows/prlint.yml/badge.svg)](https://github.com/HamroCDN/php-sdk/actions/workflows/prlint.yml) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=HamroCDN_php-sdk&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=HamroCDN_php-sdk) From 731ab32a25a6475b0aad4121646116e40011a1bf Mon Sep 17 00:00:00 2001 From: achyutkneupane Date: Tue, 28 Oct 2025 14:15:41 +0545 Subject: [PATCH 34/34] refactor: removes unused Laravel service provider from composer.json --- composer.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/composer.json b/composer.json index 60d50c6..5e78058 100644 --- a/composer.json +++ b/composer.json @@ -23,13 +23,6 @@ "HamroCDN\\Tests\\": "tests/" } }, - "extra": { - "laravel": { - "providers": [ - "HamroCDN\\Laravel\\HamroCDNServiceProvider" - ] - } - }, "authors": [ { "name": "achyutkneupane",