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) diff --git a/composer.json b/composer.json index c5dbc47..5e78058 100644 --- a/composer.json +++ b/composer.json @@ -23,13 +23,6 @@ "HamroCDN\\Tests\\": "tests/" } }, - "extra": { - "laravel": { - "providers": [ - "HamroCDN\\Laravel\\HamroCDNServiceProvider" - ] - } - }, "authors": [ { "name": "achyutkneupane", @@ -39,7 +32,8 @@ } ], "require": { - "php": ">=8.0" + "php": ">=8.0", + "guzzlehttp/guzzle": "^7.10" }, "require-dev": { "laravel/pint": "^1.25", 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 new file mode 100644 index 0000000..53a1917 --- /dev/null +++ b/src/Contracts/HamroCDNContract.php @@ -0,0 +1,60 @@ +, + * meta: array{total: int, per_page: int, page: int} + * } + */ +interface HamroCDNContract +{ + /** + * List all of your files in HamroCDN. + * + * @return HamroCDNObjectWithPagination + */ + public function index(): array; + + /** + * Fetch a file from HamroCDN. + * + * @return HamroCDNData + */ + public function fetch(string $nanoId): array; + + /** + * Upload a file to HamroCDN. + * + * @return HamroCDNData + */ + public function upload(string $filePath): array; + + /** + * Upload a file to HamroCDN by URL. + * + * @return HamroCDNData + */ + public function uploadByURL(string $url): array; +} diff --git a/src/Exceptions/HamroCDNException.php b/src/Exceptions/HamroCDNException.php new file mode 100644 index 0000000..7abce17 --- /dev/null +++ b/src/Exceptions/HamroCDNException.php @@ -0,0 +1,50 @@ + + */ + use HasConfigValues, Requestable; + + public function __construct(?string $apiKey = null, ?string $baseUrl = null, ?Client $client = null) + { + [$this->apiKey, $this->baseUrl] = $this->resolveConfig($apiKey, $baseUrl); + + $this->client = $client ?? new Client([ + 'base_uri' => "{$this->baseUrl}/", + 'timeout' => 15, + 'verify' => true, + 'headers' => [ + 'X-API-KEY' => $this->apiKey, + 'Accept' => 'application/json', + ], + ]); + } + + /** + * @return HamroCDNObjectWithPagination + * + * @throws HamroCDNException + */ + public function index(?int $per_page = 20, ?int $page = 1): array + { + /** @var HamroCDNObjectWithPagination */ + return $this->get('uploads', [ + 'per_page' => $per_page, + 'page' => $page, + ]); + } + + /** + * @throws HamroCDNException + */ + public function fetch(string $nanoId): array + { + return $this->get("uploads/{$nanoId}"); + } + + /** + * @throws HamroCDNException + */ + public function upload(string $filePath): array + { + if (! file_exists($filePath)) { + throw HamroCDNException::fileError($filePath); + } + + return $this->post('uploads', [ + 'multipart' => [ + [ + 'name' => 'file', + 'contents' => fopen($filePath, 'r'), + 'filename' => basename($filePath), + ], + ], + ]); + } + + /** + * @throws HamroCDNException + */ + public function uploadByURL(string $url): array + { + return $this->post('upload-from-url', [ + 'json' => [ + 'url' => $url, + ], + ]); + } } diff --git a/src/Traits/HasConfigValues.php b/src/Traits/HasConfigValues.php new file mode 100644 index 0000000..a4270cb --- /dev/null +++ b/src/Traits/HasConfigValues.php @@ -0,0 +1,71 @@ +apiKey = $this->stringOrNull($apiKey) + ?? $this->getConfigValue('hamrocdn.api_key') + ?? $this->getEnvValue('HAMROCDN_API_KEY'); + + $this->baseUrl = rtrim( + $baseUrl + ?? $this->getConfigValue('hamrocdn.api_url') + ?? $this->getEnvValue('HAMROCDN_API_URL') + ?? $defaultURL, + '/' + ); + + if (empty($this->apiKey)) { + throw new HamroCDNException('API key is required for HamroCDN client.'); + } + + return [$this->apiKey, $this->baseUrl]; + } + + /** + * Retrieve a configuration value if the helper exists. + */ + private function getConfigValue(string $key): ?string + { + /** @var string|null */ + return function_exists('config') ? config($key) : null; + } + + /** + * Retrieve a environment variable if the helper exists. + */ + 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 is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/Traits/Requestable.php b/src/Traits/Requestable.php new file mode 100644 index 0000000..8bc5b33 --- /dev/null +++ b/src/Traits/Requestable.php @@ -0,0 +1,72 @@ + $query + * @return T + * + * @throws HamroCDNException + */ + private function get(string $endpoint, array $query = []): array + { + try { + $response = $this->client->get($endpoint, ['query' => $query]); + + 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 HamroCDNException + */ + private function post(string $endpoint, array $options = []): array + { + try { + $response = $this->client->post($endpoint, $options); + + 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 HamroCDNException + */ + private function decodeResponse(string $json): array + { + /** @var T $decoded */ + $decoded = json_decode($json, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw HamroCDNException::invalidResponse('Invalid JSON returned by API.'); + } + + return $decoded; + } +} diff --git a/tests/ArchTest.php b/tests/ArchTest.php index b8bf9b7..5e5ea50 100644 --- a/tests/ArchTest.php +++ b/tests/ArchTest.php @@ -12,3 +12,12 @@ ->expect('HamroCDN') ->classes() ->toBeFinal(); + +arch('all contracts are interfaces') + ->expect('HamroCDN\Contracts') + ->classes() + ->toBeInterfaces(); + +arch('class implements contract') + ->expect('HamroCDN\HamroCDN') + ->toImplement('HamroCDN\Contracts\HamroCDNContract'); 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\Exceptions\HamroCDNException; +use HamroCDN\HamroCDN; + +it('returns an array of HamroCDN objects from index', function () { + $client = new HamroCDN('test-api-key', 'https://hamrocdn.com/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'); +}); + +it('uploads a file and returns a HamroCDN object', function () { + $client = new HamroCDN('test-api-key', 'https://hamrocdn.com/api'); + + $filePath = __DIR__.'/test.png'; + $upload = $client->upload($filePath); + $data = $upload['data']; + + expect($data) + ->toHaveKey('nanoId') + ->toHaveKey('user') + ->toHaveKey('delete_at') + ->toHaveKey('original'); + + $fetchResponse = $client->fetch($data['nanoId']); + $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.com/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) + ->toHaveKey('nanoId') + ->toHaveKey('user') + ->toHaveKey('delete_at') + ->toHaveKey('original'); + + expect($fetchedData['nanoId'])->toBe($data['nanoId']); +}); + +describe('exception', function () { + it('throws exception when API key is missing', function () { + $this->expectException(HamroCDNException::class); + $client = new HamroCDN(); + $client->index(); + }); + + it('throws exception when uploading a non-existing file', function () { + $client = new HamroCDN('test-api-key', 'https://hamrocdn.com/api'); + + $filePath = __DIR__.'/non-existing-file.png'; + + $this->expectException(HamroCDNException::class); + $client->upload($filePath); + }); + + it('throws exception when returns invalid json (GET)', 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); + + $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); + }); }); diff --git a/tests/test.png b/tests/test.png new file mode 100644 index 0000000..32dcb26 Binary files /dev/null and b/tests/test.png differ