diff --git a/README.md b/README.md index bd2370a..5de515d 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,51 @@ $result->confidence(); // ?float: confidence level (if available) $result->categories(); // ?array: detected categories (if available) ``` +## Response Caching + +External service responses are automatically cached to improve performance and reduce API calls. By default, all external services (PurgoMalum, Azure AI, Perspective AI, and Tisane AI) will cache their responses for 1 hour. + +The local censor service is not cached as it's already performant enough. + +### Configuring Cache + +You can configure the cache TTL and cache store in your `.env` file: + +```env +CENSOR_CACHE_ENABLED=true # Enable caching (default: true) +CENSOR_CACHE_TTL=3600 # Cache duration in seconds (default: 1 hour) +CENSOR_CACHE_STORE=redis # Cache store (default: file) +``` + +Or in your `config/censor.php`: + +```php + 'cache' => [ + 'enabled' => env('CENSOR_CACHE_ENABLED', true), + 'store' => env('CENSOR_CACHE_STORE', 'file'), + 'ttl' => env('CENSOR_CACHE_TTL', 60), + ], +``` + +The caching system uses Laravel's cache system, so it will respect your cache driver configuration (`config/cache.php`). You can use any cache driver supported by Laravel (Redis, Memcached, file, etc.). + +### Cache Keys + +Cache keys are generated using the following format: +``` +censor:{ServiceName}:{md5(text)} +``` + +For example: +``` +censor:PurgoMalum:a1b2c3d4e5f6g7h8i9j0 +``` + +This ensures unique caching for: +- Different services checking the same text +- Same service checking different texts +- Different environments using the same cache store + ## Custom Dictionaries You can add your own dictionaries or modify existing ones: diff --git a/config/censor.php b/config/censor.php index fc377bc..5ad32d3 100644 --- a/config/censor.php +++ b/config/censor.php @@ -127,4 +127,16 @@ 'censor' => [], ], + /* + |-------------------------------------------------------------------------- + | Cache configuration + |-------------------------------------------------------------------------- + | Define the configuration for the cache + | + */ + 'cache' => [ + 'enabled' => env('CENSOR_CACHE_ENABLED', true), + 'store' => env('CENSOR_CACHE_STORE', 'file'), + 'ttl' => env('CENSOR_CACHE_TTL', 60), + ], ]; diff --git a/src/Decorators/CachedProfanityChecker.php b/src/Decorators/CachedProfanityChecker.php new file mode 100644 index 0000000..114042b --- /dev/null +++ b/src/Decorators/CachedProfanityChecker.php @@ -0,0 +1,34 @@ +checker), + md5($text) + ); + + /** @var string $store */ + $store = config('censor.cache.store', 'default'); + + /** @var Result $result */ + $result = Cache::store($store)->remember($cacheKey, $this->ttl, function () use ($text): Result { + return $this->checker->check($text); + }); + + return $result; + } +} diff --git a/src/Factories/ProfanityCheckerFactory.php b/src/Factories/ProfanityCheckerFactory.php index 961532a..2c1c929 100644 --- a/src/Factories/ProfanityCheckerFactory.php +++ b/src/Factories/ProfanityCheckerFactory.php @@ -8,6 +8,7 @@ use Ninja\Censor\Checkers\PurgoMalum; use Ninja\Censor\Checkers\TisaneAI; use Ninja\Censor\Contracts\ProfanityChecker; +use Ninja\Censor\Decorators\CachedProfanityChecker; use Ninja\Censor\Enums\Service; final readonly class ProfanityCheckerFactory @@ -26,6 +27,15 @@ public static function create(Service $service, array $config = []): ProfanityCh Service::Azure => AzureAI::class, }; + if (config('censor.cache.enabled', false) === true) { + $ttl = config('censor.cache.ttl', 3600); + if (is_int($ttl) === false) { + $ttl = 3600; + } + + return new CachedProfanityChecker(new $class(...$config), $ttl); + } + return new $class(...$config); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 24b4aa7..b02399b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -28,6 +28,7 @@ protected function defineEnvironment($app): void $app['config']->set('censor.languages', ['en']); $app['config']->set('censor.dictionary_path', __DIR__.'/../resources/dict'); $app['config']->set('censor.default_service', 'local'); + $app['config']->set('censor.cache.enabled', false); $app['config']->set('censor.replacements', [ 'a' => '(a|a\.|a\-|4|@|Á|á|À|Â|à|Â|â|Ä|ä|Ã|ã|Å|å|α|Δ|Λ|λ)', 'b' => '(b|b\.|b\-|8|\|3|ß|Β|β)', diff --git a/tests/Unit/Factories/ProfanityCheckerFactoryTest.php b/tests/Unit/Factories/ProfanityCheckerFactoryTest.php index dfa3e6c..992bd9e 100644 --- a/tests/Unit/Factories/ProfanityCheckerFactoryTest.php +++ b/tests/Unit/Factories/ProfanityCheckerFactoryTest.php @@ -24,3 +24,22 @@ [Service::Perspective, PerspectiveAI::class], [Service::Tisane, TisaneAI::class], ]); + +test('factory creates cached decorator when cache is enabled', function (Service $service, string $expectedClass) { + config(['censor.cache.enabled' => true]); + $config = match ($service) { + Service::Azure => ['endpoint' => 'test', 'key' => 'test', 'version' => '2024-09-01'], + Service::Perspective, Service::Tisane => ['key' => 'test'], + default => [] + }; + + $checker = ProfanityCheckerFactory::create($service, $config, true); + expect($checker)->toBeInstanceOf(\Ninja\Censor\Decorators\CachedProfanityChecker::class); + +})->with([ + [Service::Local, Censor::class], + [Service::PurgoMalum, PurgoMalum::class], + [Service::Azure, AzureAI::class], + [Service::Perspective, PerspectiveAI::class], + [Service::Tisane, TisaneAI::class], +]);