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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,35 @@ $optimizerChain
->optimize($pathToImage);
```

### Writing an optimizer without a binary

Sometimes an optimizer has no binary and no shell command to run, for example one that sends the image to an external optimization API. For these cases implement `Spatie\ImageOptimizer\SelfHandlingOptimizer` instead. The chain delegates execution to your `handle` method rather than building and running a process.

The easiest way is to extend `Spatie\ImageOptimizer\Optimizers\BaseSelfHandlingOptimizer`, which leaves you to implement only `canHandle` and `handle`:

```php
use Psr\Log\LoggerInterface;
use Spatie\ImageOptimizer\Image;
use Spatie\ImageOptimizer\Optimizers\BaseSelfHandlingOptimizer;

class ApiOptimizer extends BaseSelfHandlingOptimizer
{
public function canHandle(Image $image): bool
{
return $image->mime() === 'image/jpeg';
}

public function handle(Image $image, LoggerInterface $logger): void
{
// Optimize $image->path() however you like, e.g. by calling an API,
// and write the optimized bytes back to that path. Throw on failure.
// The chain's logger is passed in so you can log your progress.
}
}
```

Add it to a chain with `addOptimizer()` just like any other optimizer. Failures are governed by [`throws`](#handling-errors) in exactly the same way as binary optimizers: by default the failure is logged and the chain continues, while `throws()` (or a callable) lets you abort or handle it.

## Logging the optimization process

By default the package will not throw any errors and just operate silently. To verify what the package is doing you can set a logger:
Expand Down
16 changes: 16 additions & 0 deletions src/OptimizerChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,22 @@ protected function applyOptimizer(Optimizer $optimizer, Image $image)

protected function runOptimizer(Optimizer $optimizer, Image $image)
{
if ($optimizer instanceof SelfHandlingOptimizer) {
$className = get_class($optimizer);

$this->logger->info("Executing `{$className}`");

try {
$optimizer->handle($image, $this->logger);
} catch (Throwable $exception) {
$this->logger->error("Optimizer errored with `{$exception->getMessage()}`");

throw $exception;
}

return;
}

$command = $optimizer->getCommand();

$this->logger->info("Executing `{$command}`");
Expand Down
27 changes: 27 additions & 0 deletions src/Optimizers/BaseSelfHandlingOptimizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Spatie\ImageOptimizer\Optimizers;

use Psr\Log\LoggerInterface;
use Spatie\ImageOptimizer\Image;
use Spatie\ImageOptimizer\SelfHandlingOptimizer;

abstract class BaseSelfHandlingOptimizer extends BaseOptimizer implements SelfHandlingOptimizer
{
/*
* A self-handling optimizer has no binary, so these only exist to satisfy the
* inherited Optimizer contract. The chain delegates to handle() before they
* would ever be called.
*/
public function binaryName(): string
{
return '';
}

public function getCommand(): string
{
return '';
}

abstract public function handle(Image $image, LoggerInterface $logger): void;
}
23 changes: 23 additions & 0 deletions src/SelfHandlingOptimizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace Spatie\ImageOptimizer;

use Psr\Log\LoggerInterface;

interface SelfHandlingOptimizer extends Optimizer
{
/**
* Handle the optimization directly, without a binary or shell command.
*
* The OptimizerChain delegates execution to this method instead of
* building and running a process, so the optimizer can do its own
* work (for example, calling an external optimization API). The
* chain's logger is passed in so the optimizer can log its progress.
*
* @param \Spatie\ImageOptimizer\Image $image
* @param \Psr\Log\LoggerInterface $logger
*
* @return void
*/
public function handle(Image $image, LoggerInterface $logger): void;
}
248 changes: 248 additions & 0 deletions tests/SelfHandlingOptimizerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
<?php

use Psr\Log\LoggerInterface;
use Spatie\ImageOptimizer\Image;
use Spatie\ImageOptimizer\Optimizer;
use Spatie\ImageOptimizer\OptimizerChain;
use Spatie\ImageOptimizer\Optimizers\BaseSelfHandlingOptimizer;

/**
* A self-handling optimizer that handles every image and records the image it was
* run with, so we can assert the chain delegated execution to it.
*/
class RecordingSelfHandlingOptimizer extends BaseSelfHandlingOptimizer
{
public $ranWith = null;

public $runCount = 0;

public function canHandle(Image $image): bool
{
return true;
}

public function handle(Image $image, LoggerInterface $logger): void
{
$this->ranWith = $image;
$this->runCount++;

$logger->info('Optimizing via API');
}
}

/**
* A self-handling optimizer whose handle() throws, to exercise the chain's failure
* handling for the commandless path.
*/
class ThrowingSelfHandlingOptimizer extends BaseSelfHandlingOptimizer
{
public function canHandle(Image $image): bool
{
return true;
}

public function handle(Image $image, LoggerInterface $logger): void
{
throw new RuntimeException('self-handling boom');
}
}

/**
* A self-handling optimizer that never handles the image, to assert handle() is skipped.
*/
class NonHandlingSelfHandlingOptimizer extends BaseSelfHandlingOptimizer
{
public $runCount = 0;

public function canHandle(Image $image): bool
{
return false;
}

public function handle(Image $image, LoggerInterface $logger): void
{
$this->runCount++;
}
}

/**
* A self-handling optimizer that exposes a real temp file so we can assert the chain
* cleans it up after handle(), even when handle() throws.
*/
class TmpFileSelfHandlingOptimizer extends BaseSelfHandlingOptimizer
{
public $shouldThrow = false;

public function canHandle(Image $image): bool
{
return true;
}

public function handle(Image $image, LoggerInterface $logger): void
{
if ($this->shouldThrow) {
throw new RuntimeException('self-handling boom');
}
}
}

/**
* A plain binary-style optimizer running a command that exits zero, used to
* assert the binary optimizer flow is unchanged when mixed with a self-handling optimizer.
*/
class SucceedingBinaryOptimizer implements Optimizer
{
public function binaryName(): string
{
return 'true';
}

public function canHandle(Image $image): bool
{
return true;
}

public function setImagePath(string $imagePath)
{
return $this;
}

public function setOptions(array $options = [])
{
return $this;
}

public function getCommand(): string
{
return 'true';
}

public function getTmpPath(): ?string
{
return null;
}
}

beforeEach(function () {
$this->testImage = getTempFilePath('image.jpg');

$this->optimizerChain = (new OptimizerChain())->useLogger($this->log);
});

it('delegates execution to a self-handling optimizer and passes it the image', function () {
$optimizer = new RecordingSelfHandlingOptimizer();

$this
->optimizerChain
->setOptimizers([$optimizer])
->optimize($this->testImage);

expect($optimizer->runCount)->toBe(1);
expect($optimizer->ranWith)->toBeInstanceOf(Image::class);
expect($optimizer->ranWith->path())->toBe($this->testImage);

expect($this->log->getAllLinesAsString())
->toContain('Using optimizer: `RecordingSelfHandlingOptimizer`')
->toContain('Executing `RecordingSelfHandlingOptimizer`')
->toContain('Optimizing via API');
});

it('does not run a self-handling optimizer that cannot handle the image', function () {
$optimizer = new NonHandlingSelfHandlingOptimizer();

$this
->optimizerChain
->setOptimizers([$optimizer])
->optimize($this->testImage);

expect($optimizer->runCount)->toBe(0);

expect($this->log->getAllLinesAsString())
->not->toContain('Using optimizer: `NonHandlingSelfHandlingOptimizer`');
});

it('does not throw by default when a self-handling optimizer fails', function () {
$this
->optimizerChain
->setOptimizers([new ThrowingSelfHandlingOptimizer(), new RecordingSelfHandlingOptimizer()])
->optimize($this->testImage);

expect($this->log->getAllLinesAsString())
->toContain('Using optimizer: `ThrowingSelfHandlingOptimizer`')
->toContain('error: Optimizer errored with `self-handling boom`')
->toContain('Using optimizer: `RecordingSelfHandlingOptimizer`');
});

it('aborts the chain when a self-handling optimizer fails and throws() is enabled', function () {
$this
->optimizerChain
->throws()
->setOptimizers([new ThrowingSelfHandlingOptimizer(), new RecordingSelfHandlingOptimizer()]);

expect(fn () => $this->optimizerChain->optimize($this->testImage))
->toThrow(RuntimeException::class, 'self-handling boom');

expect($this->log->getAllLinesAsString())
->not->toContain('Using optimizer: `RecordingSelfHandlingOptimizer`');
});

it('routes a self-handling optimizer failure to a custom handler', function () {
$captured = [];

$this
->optimizerChain
->throws(function ($exception, $optimizer, $image) use (&$captured) {
$captured = [$exception, $optimizer, $image];
})
->setOptimizers([new ThrowingSelfHandlingOptimizer()])
->optimize($this->testImage);

expect($captured[0])->toBeInstanceOf(RuntimeException::class);
expect($captured[1])->toBeInstanceOf(ThrowingSelfHandlingOptimizer::class);
expect($captured[2])->toBeInstanceOf(Image::class);
});

it('cleans up a self-handling optimizer temp file after running', function () {
$optimizer = new TmpFileSelfHandlingOptimizer();
$optimizer->tmpPath = tempnam(sys_get_temp_dir(), 'selfhandling');

expect(file_exists($optimizer->tmpPath))->toBeTrue();

$this
->optimizerChain
->setOptimizers([$optimizer])
->optimize($this->testImage);

expect(file_exists($optimizer->tmpPath))->toBeFalse();
});

it('cleans up a self-handling optimizer temp file even when handle() throws', function () {
$optimizer = new TmpFileSelfHandlingOptimizer();
$optimizer->shouldThrow = true;
$optimizer->tmpPath = tempnam(sys_get_temp_dir(), 'selfhandling');

expect(file_exists($optimizer->tmpPath))->toBeTrue();

$this
->optimizerChain
->setOptimizers([$optimizer])
->optimize($this->testImage);

expect(file_exists($optimizer->tmpPath))->toBeFalse();
});

it('runs binary and self-handling optimizers together, leaving the binary optimizer flow unchanged', function () {
$selfHandling = new RecordingSelfHandlingOptimizer();

$this
->optimizerChain
->setOptimizers([new SucceedingBinaryOptimizer(), $selfHandling])
->optimize($this->testImage);

expect($selfHandling->runCount)->toBe(1);

expect($this->log->getAllLinesAsString())
->toContain('Using optimizer: `SucceedingBinaryOptimizer`')
->toContain('Executing `true`')
->toContain('Using optimizer: `RecordingSelfHandlingOptimizer`');
});