Enabled caching for file hashes, zip files, translation definitions and markdown parsing

This commit is contained in:
Chris Kankiewicz
2020-05-29 15:59:48 -07:00
parent 209d108e6f
commit 8e5b5b4597
23 changed files with 1120 additions and 130 deletions

View File

@@ -10,12 +10,20 @@ jobs:
allow_failures:
- php: nightly
services:
- memcached
- redis
cache:
directories:
- $HOME/.composer/cache
- $HOME/.npm
- app/vendor
before_install:
- printf "\n" | pecl install -f apcu-5.1.18 memcached
- echo "extension = redis.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
install:
- composer install --no-suggest
- npm ci

View File

@@ -4,10 +4,12 @@ LABEL maintainer="Chris Kankiewicz <Chris@ChrisKankiewicz.com>"
COPY .docker/apache/config/000-default.conf /etc/apache2/sites-available/000-default.conf
COPY .docker/php/config/php.ini /usr/local/etc/php/php.ini
RUN apt-get update && apt-get install --assume-yes libzip-dev \
RUN apt-get update && apt-get install --assume-yes libmemcached-dev libzip-dev \
&& rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-install zip && pecl install xdebug && docker-php-ext-enable xdebug
RUN docker-php-ext-install zip \
&& pecl install memcached redis xdebug \
&& docker-php-ext-enable memcached redis xdebug
RUN a2enmod rewrite

View File

@@ -147,14 +147,6 @@ return [
*/
'max_hash_size' => Helpers::env('MAX_HASH_SIZE', 1000000000),
/**
* Path to the view cache directory.
* Set to 'false' to disable view caching entirely.
*
* Default value: 'app/cache/views'
*/
'view_cache' => Helpers::env('VIEW_CACHE', 'app/cache/views'),
/**
* HTTP expires values.
*

71
app/config/cache.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
use App\Support\Helpers;
return [
/**
* The application cache driver. Setting this value to 'array' will disable
* the cache across requests. Additional driver-specific options may require
* configuration below.
*
* Possible values: apcu, array, file, memcached, redis, php-file
*
* Default value: 'file'
*/
'cache_driver' => Helpers::env('CACHE_DRIVER', 'file'),
/**
* The app cache lifetime (in seconds). If set to 0, cache indefinitely.
*
* Default value: 0 (indefinitely)
*/
'cache_lifetime' => Helpers::env('CACHE_LIFETIME', 0),
/**
* Path to the view cache directory. Set to 'false' to disable
* view caching entirely. The view cache is separate from the application
* cache defined above.
*
* Default value: 'app/cache/views'
*/
'view_cache' => Helpers::env('VIEW_CACHE', 'app/cache/views'),
/**
* The Memcached configuration closure. This option is used when the
* 'cache_driver' configuration option is set to 'memcached'. The closure
* receives a Memcached object as it's only parameter. You can use this
* object to configure the Memcached connection. At a minimum you must
* connect to one or more Memcached servers via the 'addServer()' or
* 'addServers()' methods.
*
* Reference the PHP Memcached documentation for Memcached configuration
* options: https://secure.php.net/manual/en/book.memcached.php
*
* Default value: Adds a server at localhost:11211
*/
'memcached_config' => DI\value(function (Memcached $memcached): void {
$memcached->addServer(
Helpers::env('MEMCACHED_HOST', 'localhost'),
Helpers::env('MEMCACHED_PORT', 11211)
);
}),
/**
* The Redis configuration closure. This option is used when the
* 'cache_driver' configuration option is set to 'redis'. The closure
* receives a Redis object as it's only parameter. You can use this object
* to configure the Redis connection. At a minimum you must connect to one
* or more Redis servers via the 'connect()' or 'pconnect()' methods.
*
* Reference the phpredis documentation for Redis configuration options:
* https://github.com/phpredis/phpredis#readme
*
* Default value: Adds a server at localhost:6379
*/
'redis_config' => DI\value(function (Redis $redis): void {
$redis->pconnect(
Helpers::env('REDIS_HOST', 'localhost'),
Helpers::env('REDIS_PORT', 6379)
);
}),
];

View File

@@ -14,6 +14,7 @@ return [
'asset_path' => DI\string('{app_path}/assets'),
'cache_path' => DI\string('{app_path}/cache'),
'config_path' => DI\string('{app_path}/config'),
'source_path' => DI\string('{app_path}/src'),
'translations_path' => DI\string('{app_path}/translations'),
'views_path' => DI\string('{app_path}/views'),
'icons_config' => DI\string('{config_path}/icons.php'),
@@ -39,12 +40,6 @@ return [
'type' => SortMethods\Type::class,
],
/** Array of available translation languages */
'translations' => [
'de', 'en', 'es', 'fr', 'id', 'it', 'kr', 'nl',
'pl', 'pt-BR', 'ro', 'ru', 'zh-CN', 'zh-TW'
],
/** Array of view functions */
'view_functions' => [
ViewFunctions\Asset::class,
@@ -62,6 +57,7 @@ return [
/** Container definitions */
Symfony\Component\Finder\Finder::class => DI\factory(Factories\FinderFactory::class),
Symfony\Contracts\Cache\CacheInterface::class => DI\Factory(Factories\CacheFactory::class),
Symfony\Contracts\Translation\TranslatorInterface::class => DI\factory(Factories\TranslationFactory::class),
Slim\Views\Twig::class => DI\factory(Factories\TwigFactory::class),
Whoops\RunInterface::class => DI\create(Whoops\Run::class),

View File

@@ -7,6 +7,7 @@ use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use SplFileInfo;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class FileInfoController
@@ -14,6 +15,9 @@ class FileInfoController
/** @var Container The application container */
protected $container;
/** @var CacheInterface The application cache */
protected $cache;
/** @var TranslatorInterface Translator component */
protected $translator;
@@ -21,13 +25,16 @@ class FileInfoController
* Create a new FileInfoHandler object.
*
* @param \DI\Container $container
* @param \Symfony\Contracts\Cache\CacheInterface $cache
* @param \Symfony\Contracts\Translation\TranslatorInterface $translator
*/
public function __construct(
Container $container,
CacheInterface $cache,
TranslatorInterface $translator
) {
$this->container = $container;
$this->cache = $cache;
$this->translator = $translator;
}
@@ -55,13 +62,18 @@ class FileInfoController
return $response->withStatus(500, $this->translator->trans('error.file_size_exceeded'));
}
$response->getBody()->write(json_encode([
'hashes' => [
'md5' => hash('md5', file_get_contents($file->getPathname())),
'sha1' => hash('sha1', file_get_contents($file->getPathname())),
'sha256' => hash('sha256', file_get_contents($file->getPathname())),
]
]));
$response->getBody()->write($this->cache->get(
sprintf('file-info-%s', sha1($file->getRealPath())),
function () use ($file): string {
return json_encode([
'hashes' => [
'md5' => hash_file('md5', $file->getPathname()),
'sha1' => hash_file('sha1', $file->getPathname()),
'sha256' => hash_file('sha256', $file->getPathname()),
]
]);
}
));
return $response->withHeader('Content-Type', 'application/json');
}

View File

@@ -10,6 +10,7 @@ use Slim\Psr7\Request;
use Slim\Psr7\Response;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use ZipArchive;
@@ -18,6 +19,9 @@ class ZipController
/** @var Container The application container */
protected $container;
/** @var CacheInterface The application cache */
protected $cache;
/** @var Finder The Finder Component */
protected $finder;
@@ -28,15 +32,18 @@ class ZipController
* Create a new ZipHandler object.
*
* @param \DI\Container $container
* @param \Symfony\Contracts\Cache\CacheInterface $cache
* @param \PhpCsFixer\Finder $finder
* @param \Symfony\Contracts\Translation\TranslatorInterface $translator
*/
public function __construct(
Container $container,
CacheInterface $cache,
Finder $finder,
TranslatorInterface $translator
) {
$this->container = $container;
$this->cache = $cache;
$this->finder = $finder;
$this->translator = $translator;
}
@@ -57,7 +64,11 @@ class ZipController
return $response->withStatus(404, $this->translator->trans('error.file_not_found'));
}
$response->getBody()->write($this->createZip($path)->getContents());
$response->getBody()->write(
$this->cache->get(sprintf('zip-%s', sha1($path)), function () use ($path): string {
return $this->createZip($path)->getContents();
})
);
return $response->withHeader('Content-Type', 'application/zip')
->withHeader('Content-Disposition', sprintf(

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Factories;
use App\Exceptions\InvalidConfiguration;
use DI\Container;
use Symfony\Component\Cache\Adapter\ApcuAdapter;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\MemcachedAdapter;
use Symfony\Component\Cache\Adapter\PhpFilesAdapter;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Contracts\Cache\CacheInterface;
class CacheFactory
{
/** @const Namespace for external cache drivers */
protected const NAMESPACE_EXTERNAL = 'directory_lister';
/** @const Namespace for internal cache drivers */
protected const NAMESAPCE_INTERNAL = 'app';
/** @var Container The application container */
protected $container;
/**
* Create a new CacheFactory object.
*
* @param \DI\Container $container
*/
public function __construct(Container $container)
{
$this->container = $container;
}
/**
* Initialize and return a CacheInterface.
*
* @return \Symfony\Contracts\Cache\CacheInterface
*/
public function __invoke(): CacheInterface
{
switch ($this->container->get('cache_driver')) {
case 'apcu':
return new ApcuAdapter(
self::NAMESPACE_EXTERNAL,
$this->container->get('cache_lifetime')
);
case 'array':
return new ArrayAdapter($this->container->get('cache_lifetime'));
case 'file':
return new FilesystemAdapter(
self::NAMESAPCE_INTERNAL,
$this->container->get('cache_lifetime'),
$this->container->get('cache_path')
);
case 'memcached':
$this->container->call('memcached_config', [$memcached = new \Memcached]);
return new MemcachedAdapter(
$memcached,
self::NAMESPACE_EXTERNAL,
$this->container->get('cache_lifetime')
);
case 'php-file':
return new PhpFilesAdapter(
self::NAMESAPCE_INTERNAL,
$this->container->get('cache_lifetime'),
$this->container->get('cache_path')
);
case 'redis':
$this->container->call('redis_config', [$redis = new \Redis]);
return new RedisAdapter(
$redis,
self::NAMESPACE_EXTERNAL,
$this->container->get('cache_lifetime')
);
default:
throw InvalidConfiguration::fromConfig('cache_driver', $this->container->get('cache_driver'));
}
}
}

View File

@@ -4,8 +4,11 @@ namespace App\Factories;
use DI\Container;
use RuntimeException;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Symfony\Component\Translation\Loader\YamlFileLoader;
use Symfony\Component\Translation\Translator;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class TranslationFactory
@@ -13,14 +16,18 @@ class TranslationFactory
/** @var Container The applicaiton container */
protected $container;
/** @var CacheInterface The application cache */
protected $cache;
/**
* Create a new TranslationFactory object.
*
* @param \DI\Container $container
*/
public function __construct(Container $container)
public function __construct(Container $container, CacheInterface $cache)
{
$this->container = $container;
$this->cache = $cache;
}
/**
@@ -47,4 +54,20 @@ class TranslationFactory
return $translator;
}
/**
* Get an array of available translation languages.
*
* @return array
*/
protected function translations(): array
{
return $this->cache->get('translations', function (): array {
return array_values(array_map(function (SplFileInfo $file): string {
return $file->getBasename('.yaml');
}, iterator_to_array(
Finder::create()->in($this->container->get('translations_path'))->name('*.yaml')
)));
});
}
}

View File

@@ -3,12 +3,25 @@
namespace App\ViewFunctions;
use ParsedownExtra;
use Symfony\Contracts\Cache\CacheInterface;
class Markdown extends ViewFunction
{
/** @var string The function name */
protected $name = 'markdown';
/** @var ParsedownExtra The markdown parser */
protected $parser;
/** @var CacheInterface */
protected $cache;
public function __construct(ParsedownExtra $parser, CacheInterface $cache)
{
$this->parser = $parser;
$this->cache = $cache;
}
/**
* Parses a string of markdown into HTML.
*
@@ -16,8 +29,10 @@ class Markdown extends ViewFunction
*
* @return string
*/
public function __invoke(string $string)
public function __invoke(string $string): string
{
return ParsedownExtra::instance()->parse($string);
return $this->cache->get(md5($string), function () use ($string): string {
return $this->parser->parse($string);
});
}
}

View File

@@ -24,6 +24,7 @@
"slim/psr7": "^1.0",
"slim/slim": "^4.3",
"slim/twig-view": "^3.0",
"symfony/cache": "^5.0",
"symfony/finder": "^5.0",
"symfony/translation": "^5.0",
"symfony/yaml": "^5.0",
@@ -39,6 +40,11 @@
"symfony/var-dumper": "^5.0",
"vimeo/psalm": "^3.6"
},
"suggest": {
"ext-apcu": "Required to use the APCu cache driver",
"ext-memcached": "Required to use the Memcached driver",
"ext-redis": "Required to use the Redis cache driver"
},
"autoload": {
"psr-4": {
"App\\": "app/src/"

817
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,18 @@ services:
extra_hosts:
- host.docker.internal:${DOCKER_HOST_IP}
memcached:
container_name: directory-lister-memcached
ports:
- ${MEMCACHED_PORT:-11211}:11211
image: memcached:1.6
redis:
container_name: directory-lister-redis
ports:
- ${REDIS_PORT:-6379}:6379
image: redis:6.0
networks:
default:
external:

View File

@@ -14,6 +14,7 @@ Dotenv::createImmutable(__DIR__)->safeLoad();
// Initialize the application
$app = (new ContainerBuilder)->addDefinitions(
__DIR__ . '/app/config/cache.php',
__DIR__ . '/app/config/app.php',
__DIR__ . '/app/config/container.php'
)->build()->call(AppManager::class);

View File

@@ -1,10 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="3.11.2@d470903722cfcbc1cd04744c5491d3e6d13ec3d9">
<files psalm-version="3.11.5@3c60609c218d4d4b3b257728b8089094e5c6c6c2">
<file src="app/src/Controllers/DirectoryController.php">
<PossiblyInvalidMethodCall occurrences="1">
<code>current</code>
</PossiblyInvalidMethodCall>
</file>
<file src="app/src/Factories/CacheFactory.php">
<UndefinedFunction occurrences="2">
<code>'memcached_config'</code>
<code>'redis_config'</code>
</UndefinedFunction>
</file>
<file src="app/src/Factories/TwigFactory.php">
<InvalidMethodCall occurrences="1">
<code>name</code>

View File

@@ -17,4 +17,13 @@ DATE_FORMAT="Y-m-d H:i:s"
MAX_HASH_SIZE=1000000000
CACHE_DRIVER=array
CACHE_LIFETIME=0
MEMCACHED_HOST=127.0.0.1
MEMCACHED_PORT=11211
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
VIEW_CACHE=false

View File

@@ -13,7 +13,11 @@ class FileInfoControllerTest extends TestCase
{
public function test_it_can_return_a_successful_response(): void
{
$handler = new FileInfoController($this->container, $this->container->get(TranslatorInterface::class));
$handler = new FileInfoController(
$this->container,
$this->cache,
$this->container->get(TranslatorInterface::class)
);
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn(['info' => 'README.md']);
@@ -33,7 +37,11 @@ class FileInfoControllerTest extends TestCase
public function test_it_can_return_a_not_found_response(): void
{
$handler = new FileInfoController($this->container, $this->container->get(TranslatorInterface::class));
$handler = new FileInfoController(
$this->container,
$this->cache,
$this->container->get(TranslatorInterface::class)
);
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn(['info' => 'not_a_file.test']);
@@ -47,7 +55,11 @@ class FileInfoControllerTest extends TestCase
public function test_it_returns_an_error_when_file_size_is_too_large(): void
{
$this->container->set('max_hash_size', 10);
$handler = new FileInfoController($this->container, $this->container->get(TranslatorInterface::class));
$handler = new FileInfoController(
$this->container,
$this->cache,
$this->container->get(TranslatorInterface::class)
);
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn(['info' => 'README.md']);

View File

@@ -10,12 +10,13 @@ use Symfony\Component\Finder\Finder;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tests\TestCase;
class ZipHandlerTest extends TestCase
class ZipControllerTest extends TestCase
{
public function test_it_returns_a_successful_response_for_a_zip_request(): void
{
$handler = new ZipController(
$this->container,
$this->cache,
new Finder,
$this->container->get(TranslatorInterface::class)
);
@@ -37,6 +38,7 @@ class ZipHandlerTest extends TestCase
{
$handler = new ZipController(
$this->container,
$this->cache,
new Finder,
$this->container->get(TranslatorInterface::class)
);
@@ -56,6 +58,7 @@ class ZipHandlerTest extends TestCase
$this->container->set('zip_downloads', false);
$handler = new ZipController(
$this->container,
$this->cache,
new Finder,
$this->container->get(TranslatorInterface::class)
);

View File

@@ -0,0 +1,74 @@
<?php
namespace Tests\Factories;
use App\Exceptions\InvalidConfiguration;
use App\Factories\CacheFactory;
use Symfony\Component\Cache\Adapter;
use Tests\TestCase;
class CacheFactoryTest extends TestCase
{
public function test_it_can_compose_an_apcu_adapter(): void
{
$this->container->set('cache_driver', 'apcu');
$cache = (new CacheFactory($this->container))();
$this->assertInstanceOf(Adapter\ApcuAdapter::class, $cache);
}
public function test_it_can_compose_an_array_adapter(): void
{
$this->container->set('cache_driver', 'array');
$cache = (new CacheFactory($this->container))();
$this->assertInstanceOf(Adapter\ArrayAdapter::class, $cache);
}
public function test_it_can_compose_a_filesystem_adapter(): void
{
$this->container->set('cache_driver', 'file');
$cache = (new CacheFactory($this->container))();
$this->assertInstanceOf(Adapter\FilesystemAdapter::class, $cache);
}
public function test_it_can_compose_a_memcached_adapter(): void
{
$this->container->set('cache_driver', 'memcached');
$cache = (new CacheFactory($this->container))();
$this->assertInstanceOf(Adapter\MemcachedAdapter::class, $cache);
}
public function test_it_can_compose_a_php_files_adapter(): void
{
$this->container->set('cache_driver', 'php-file');
$cache = (new CacheFactory($this->container))();
$this->assertInstanceOf(Adapter\PhpFilesAdapter::class, $cache);
}
public function test_it_can_compose_a_redis_adapter(): void
{
$this->container->set('cache_driver', 'redis');
$cache = (new CacheFactory($this->container))();
$this->assertInstanceOf(Adapter\RedisAdapter::class, $cache);
}
public function test_it_throws_a_runtime_exception_with_an_invalid_sort_order(): void
{
$this->container->set('cache_driver', 'invalid');
$this->expectException(InvalidConfiguration::class);
(new CacheFactory($this->container))();
}
}

View File

@@ -12,7 +12,7 @@ class FinderFactoryTest extends TestCase
{
public function test_it_can_compose_the_finder_component(): void
{
$finder = (new FinderFactory($this->container))();
$finder = (new FinderFactory($this->container, $this->cache))();
$this->assertInstanceOf(Finder::class, $finder);
@@ -35,7 +35,7 @@ class FinderFactoryTest extends TestCase
}
));
$finder = (new FinderFactory($this->container))();
$finder = (new FinderFactory($this->container, $this->cache))();
$finder->in($this->filePath('subdir'))->depth(0);
$this->assertEquals([
@@ -51,7 +51,7 @@ class FinderFactoryTest extends TestCase
{
$this->container->set('reverse_sort', true);
$finder = (new FinderFactory($this->container))();
$finder = (new FinderFactory($this->container, $this->cache))();
$finder->in($this->filePath('subdir'))->depth(0);
$this->assertEquals([
@@ -69,7 +69,7 @@ class FinderFactoryTest extends TestCase
'subdir/alpha.scss', 'subdir/charlie.bash', '**/*.yaml'
]);
(new FinderFactory($this->container))();
(new FinderFactory($this->container, $this->cache))();
$finder = $this->container->get(Finder::class);
$finder->in($this->filePath('subdir'))->depth(0);
@@ -87,7 +87,7 @@ class FinderFactoryTest extends TestCase
$this->expectException(RuntimeException::class);
(new FinderFactory($this->container))();
(new FinderFactory($this->container, $this->cache))();
}
protected function getFilesArray(Finder $finder): array

View File

@@ -11,7 +11,7 @@ class TranslationFactoryTest extends TestCase
{
public function test_it_registers_the_translation_component(): void
{
$translator = (new TranslationFactory($this->container))();
$translator = (new TranslationFactory($this->container, $this->cache))();
$this->assertEquals('en', $translator->getLocale());
$this->assertInstanceOf(MessageCatalogue::class, $translator->getCatalogue('de'));
@@ -32,6 +32,6 @@ class TranslationFactoryTest extends TestCase
$this->expectException(RuntimeException::class);
$this->container->set('language', 'xx');
(new TranslationFactory($this->container))();
(new TranslationFactory($this->container, $this->cache))();
}
}

View File

@@ -6,12 +6,17 @@ use DI\Container;
use DI\ContainerBuilder;
use Dotenv\Dotenv;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Contracts\Cache\CacheInterface;
class TestCase extends PHPUnitTestCase
{
/** @var Container The test container */
protected $container;
/** @var CacheInterface The test cache */
protected $cache;
/** @var string Path to test files directory */
protected $testFilesPath = __DIR__ . '/_files';
@@ -25,10 +30,13 @@ class TestCase extends PHPUnitTestCase
Dotenv::createImmutable(__DIR__)->safeLoad();
$this->container = (new ContainerBuilder)->addDefinitions(
dirname(__DIR__) . '/app/config/cache.php',
dirname(__DIR__) . '/app/config/app.php',
dirname(__DIR__) . '/app/config/container.php'
)->build();
$this->cache = new ArrayAdapter($this->container->get('cache_lifetime'));
$this->container->set('base_path', $this->testFilesPath);
$this->container->set('asset_path', $this->filePath('app/assets'));
$this->container->set('cache_path', $this->filePath('app/cache'));

View File

@@ -3,15 +3,18 @@
namespace Tests\ViewFunctions;
use App\ViewFunctions\Markdown;
use ParsedownExtra;
use Tests\TestCase;
class MarkdownTest extends TestCase
{
public function test_it_can_parse_markdown_into_html(): void
{
$markdown = new Markdown(new ParsedownExtra, $this->cache);
$this->assertEquals(
'<p><strong>Test</strong> <code>markdown</code>, <del>please</del> <em>ignore</em></p>',
(new Markdown)('**Test** `markdown`, ~~please~~ _ignore_')
$markdown('**Test** `markdown`, ~~please~~ _ignore_')
);
}
}