Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ composer require mathiasgrimm/glimpse-php
```

```php
use GlimpseImg\Client;
use GlimpseImg\ImageFormat;
use MathiasGrimm\GlimpsePhp\Client;
use MathiasGrimm\GlimpsePhp\ImageFormat;
use Illuminate\Http\Client\Factory;

$glimpse = new Client(new Factory, 'your-api-token');
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@
},
"autoload": {
"psr-4": {
"GlimpseImg\\": "src/"
"MathiasGrimm\\GlimpsePhp\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"GlimpseImg\\Tests\\": "tests/"
"MathiasGrimm\\GlimpsePhp\\Tests\\": "tests/"
}
},
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion src/ApiException.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

use RuntimeException;

Expand Down
2 changes: 1 addition & 1 deletion src/AuthException.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

class AuthException extends ApiException {}
42 changes: 41 additions & 1 deletion src/Client.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

use Closure;
use Illuminate\Http\Client\Factory;
Expand Down Expand Up @@ -138,6 +138,14 @@ private function guard(Response $response): Response
throw new AuthException('Invalid or missing token.');
}

if ($response->status() === 403) {
$message = $response->json('message');

throw new ForbiddenException(
is_string($message) && $message !== '' ? $message : 'This token may not call this endpoint.',
);
}

if ($response->status() === 422) {
$message = $response->json('message');
$errors = $response->json('errors');
Expand All @@ -148,6 +156,15 @@ private function guard(Response $response): Response
);
}

if ($response->status() === 429) {
$message = $response->json('message');

throw new RateLimitException(
is_string($message) && $message !== '' ? $message : 'The API rate limit was reached.',
$this->retryAfterSeconds($response),
);
}

if ($response->failed()) {
$message = $response->json('message');

Expand All @@ -161,6 +178,29 @@ private function guard(Response $response): Response
return $response;
}

/**
* Parse the Retry-After header, which RFC 9110 allows as either
* delay-seconds or an HTTP date. Negative and fractional delays are
* clamped so a caller can sleep the value as-is; an absent or
* unparseable header yields null.
*/
private function retryAfterSeconds(Response $response): ?int
{
$header = $response->header('Retry-After');

if ($header === '') {
return null;
}

if (is_numeric($header)) {
return max(0, (int) ceil((float) $header));
}

$timestamp = strtotime($header);

return $timestamp === false ? null : max(0, $timestamp - time());
}

private function requireToken(): string
{
$token = $this->token instanceof Closure ? ($this->token)() : $this->token;
Expand Down
5 changes: 5 additions & 0 deletions src/ForbiddenException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

namespace MathiasGrimm\GlimpsePhp;

class ForbiddenException extends ApiException {}
2 changes: 1 addition & 1 deletion src/FrameCounter.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

use Imagick;
use ImagickException;
Expand Down
2 changes: 1 addition & 1 deletion src/ImageFormat.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

enum ImageFormat: string
{
Expand Down
2 changes: 1 addition & 1 deletion src/ImageInfo.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

final readonly class ImageInfo
{
Expand Down
2 changes: 1 addition & 1 deletion src/ImageResolution.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

final readonly class ImageResolution
{
Expand Down
2 changes: 1 addition & 1 deletion src/ImageResult.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

final readonly class ImageResult
{
Expand Down
2 changes: 1 addition & 1 deletion src/ProbeResult.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

final readonly class ProbeResult
{
Expand Down
14 changes: 14 additions & 0 deletions src/RateLimitException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace MathiasGrimm\GlimpsePhp;

class RateLimitException extends ApiException
{
/**
* @param ?int $retryAfterSeconds Seconds to wait from the Retry-After header (never negative), or null when the header is missing or unparseable
*/
public function __construct(string $message, public readonly ?int $retryAfterSeconds = null)
{
parent::__construct($message);
}
}
2 changes: 1 addition & 1 deletion src/SampleProbe.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

use Imagick;
use ImagickException;
Expand Down
2 changes: 1 addition & 1 deletion src/SizeEstimate.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

final readonly class SizeEstimate
{
Expand Down
2 changes: 1 addition & 1 deletion src/UsagePeriod.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

use DateTimeImmutable;
use Exception;
Expand Down
2 changes: 1 addition & 1 deletion src/UsageSummary.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

final readonly class UsageSummary
{
Expand Down
2 changes: 1 addition & 1 deletion src/User.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

use DateTimeImmutable;
use Exception;
Expand Down
2 changes: 1 addition & 1 deletion src/ValidationException.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg;
namespace MathiasGrimm\GlimpsePhp;

class ValidationException extends ApiException
{
Expand Down
103 changes: 93 additions & 10 deletions tests/ClientTest.php
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
<?php

use GlimpseImg\ApiException;
use GlimpseImg\AuthException;
use GlimpseImg\Client;
use GlimpseImg\ImageFormat;
use GlimpseImg\ImageInfo;
use GlimpseImg\SizeEstimate;
use GlimpseImg\Tests\Fixtures\Images;
use GlimpseImg\UsageSummary;
use GlimpseImg\User;
use GlimpseImg\ValidationException;
use Illuminate\Http\Client\Factory;
use Illuminate\Http\Client\Request;
use MathiasGrimm\GlimpsePhp\ApiException;
use MathiasGrimm\GlimpsePhp\AuthException;
use MathiasGrimm\GlimpsePhp\Client;
use MathiasGrimm\GlimpsePhp\ForbiddenException;
use MathiasGrimm\GlimpsePhp\ImageFormat;
use MathiasGrimm\GlimpsePhp\ImageInfo;
use MathiasGrimm\GlimpsePhp\RateLimitException;
use MathiasGrimm\GlimpsePhp\SizeEstimate;
use MathiasGrimm\GlimpsePhp\Tests\Fixtures\Images;
use MathiasGrimm\GlimpsePhp\UsageSummary;
use MathiasGrimm\GlimpsePhp\User;
use MathiasGrimm\GlimpsePhp\ValidationException;

function fakeHttp(array $responses = []): Factory
{
Expand Down Expand Up @@ -364,6 +366,87 @@ function client(Factory $http, Closure|string|null $token = 'test-token', string
}
});

test('a 403 response maps to ForbiddenException with the API message', function () {
$http = fakeHttp(['*/v1/convert' => Factory::response(['message' => 'Invalid ability provided.'], 403)]);

expect(fn () => client($http)->convert(Images::png(), ImageFormat::Jpg))
->toThrow(ForbiddenException::class, 'Invalid ability provided.');
});

test('a 403 response without a message gets the fallback text', function () {
$http = fakeHttp(['*/v1/convert' => Factory::response([], 403)]);

expect(fn () => client($http)->convert(Images::png(), ImageFormat::Jpg))
->toThrow(ForbiddenException::class, 'This token may not call this endpoint.');
});

test('a 429 response maps to RateLimitException carrying Retry-After', function () {
$http = fakeHttp(['*/v1/analyze' => Factory::response(['message' => 'Too Many Requests'], 429, ['Retry-After' => '17'])]);

try {
client($http)->analyze(ImageFormat::Jpg, 2_500_000);
$this->fail('Expected a RateLimitException.');
} catch (RateLimitException $e) {
expect($e->getMessage())->toBe('Too Many Requests')
->and($e->retryAfterSeconds)->toBe(17);
}
});

test('a 429 response without a Retry-After header yields a null delay', function () {
$http = fakeHttp(['*/v1/analyze' => Factory::response(['message' => 'Too Many Requests'], 429)]);

try {
client($http)->analyze(ImageFormat::Jpg, 2_500_000);
$this->fail('Expected a RateLimitException.');
} catch (RateLimitException $e) {
expect($e->retryAfterSeconds)->toBeNull();
}
});

test('a 429 response prefers the API message over the fallback', function () {
$http = fakeHttp(['*/v1/analyze' => Factory::response(['message' => 'Shared token limit reached.'], 429)]);

expect(fn () => client($http)->analyze(ImageFormat::Jpg, 2_500_000))
->toThrow(RateLimitException::class, 'Shared token limit reached.');
});

test('Retry-After parsing clamps and rounds odd delay values', function (string $header, ?int $expected) {
$http = fakeHttp(['*/v1/analyze' => Factory::response([], 429, ['Retry-After' => $header])]);

try {
client($http)->analyze(ImageFormat::Jpg, 2_500_000);
$this->fail('Expected a RateLimitException.');
} catch (RateLimitException $e) {
expect($e->retryAfterSeconds)->toBe($expected);
}
})->with([
'negative clamps to zero' => ['-5', 0],
'fractional rounds up' => ['2.5', 3],
'garbage yields null' => ['soon', null],
]);

test('an HTTP-date Retry-After resolves to the remaining seconds', function () {
$http = fakeHttp(['*/v1/analyze' => Factory::response([], 429, [
'Retry-After' => gmdate('D, d M Y H:i:s \G\M\T', time() + 30),
])]);

try {
client($http)->analyze(ImageFormat::Jpg, 2_500_000);
$this->fail('Expected a RateLimitException.');
} catch (RateLimitException $e) {
expect($e->retryAfterSeconds)->toBeGreaterThanOrEqual(28)
->and($e->retryAfterSeconds)->toBeLessThanOrEqual(30);
}
});

test('the new exceptions stay catchable as ApiException', function () {
$limited = fakeHttp(['*/v1/analyze' => Factory::response([], 429)]);
$forbidden = fakeHttp(['*/v1/convert' => Factory::response([], 403)]);

expect(fn () => client($limited)->analyze(ImageFormat::Jpg, 2_500_000))->toThrow(ApiException::class)
->and(fn () => client($forbidden)->convert(Images::png(), ImageFormat::Jpg))->toThrow(ApiException::class);
});

test('other failures map to ApiException with the status code', function () {
$http = fakeHttp(['*/v1/optimize' => Factory::response(['message' => 'Server Error'], 500)]);

Expand Down
2 changes: 1 addition & 1 deletion tests/Fixtures/Images.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace GlimpseImg\Tests\Fixtures;
namespace MathiasGrimm\GlimpsePhp\Tests\Fixtures;

final class Images
{
Expand Down
4 changes: 2 additions & 2 deletions tests/FrameCounterTest.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php

use GlimpseImg\FrameCounter;
use GlimpseImg\Tests\Fixtures\Images;
use MathiasGrimm\GlimpsePhp\FrameCounter;
use MathiasGrimm\GlimpsePhp\Tests\Fixtures\Images;

test('counts the frames of an animated gif without any image extension', function () {
expect((new FrameCounter)->count(Images::animatedGif()))->toBe(3);
Expand Down
4 changes: 2 additions & 2 deletions tests/ImageFormatTest.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php

use GlimpseImg\ImageFormat;
use GlimpseImg\Tests\Fixtures\Images;
use MathiasGrimm\GlimpsePhp\ImageFormat;
use MathiasGrimm\GlimpsePhp\Tests\Fixtures\Images;

test('tryFromBinary detects each supported format by its magic numbers', function () {
expect(ImageFormat::tryFromBinary(Images::jpg()))->toBe(ImageFormat::Jpg)
Expand Down
2 changes: 1 addition & 1 deletion tests/Pest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

use GlimpseImg\Tests\Fixtures\Images;
use MathiasGrimm\GlimpsePhp\Tests\Fixtures\Images;

/**
* A canned successful analyze-endpoint response envelope.
Expand Down
4 changes: 2 additions & 2 deletions tests/SampleProbeTest.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php

use GlimpseImg\SampleProbe;
use GlimpseImg\Tests\Fixtures\Images;
use MathiasGrimm\GlimpsePhp\SampleProbe;
use MathiasGrimm\GlimpsePhp\Tests\Fixtures\Images;

/**
* Generate a jpeg whose content complexity is controlled: noisy pixels
Expand Down
Loading