Skip to content

Commit

Permalink
implement caching headers and -response (#4)
Browse files Browse the repository at this point in the history
* implement caching headers and -response
* update code quality tools
  • Loading branch information
rieschl authored May 22, 2020
1 parent a448d7d commit c9443c8
Show file tree
Hide file tree
Showing 4 changed files with 138 additions and 12 deletions.
7 changes: 4 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"php": "^7.4",
"ext-fileinfo": "*",
"ext-mbstring": "*",
"fig/http-message-util": "^1.1",
"laminas/laminas-diactoros": "^2.3",
"narrowspark/mimetypes": "^1.6",
"psr/container": "^1.0",
Expand All @@ -18,14 +19,14 @@
},
"require-dev": {
"eventjet/coding-standard": "^3.1",
"infection/infection": "^0.15.0",
"infection/infection": "^0.16",
"maglnet/composer-require-checker": "^2.0",
"nette/php-generator": "^3.3",
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^0.12.5",
"phpstan/phpstan-phpunit": "^0.12.6",
"phpstan/phpstan-strict-rules": "^0.12.1",
"phpunit/phpunit": "^8.5",
"phpunit/phpunit": "^9.0",
"thecodingmachine/phpstan-safe-rule": "^1.0",
"vimeo/psalm": "^3.8"
},
Expand Down Expand Up @@ -56,7 +57,7 @@
"check-deps": "vendor/bin/composer-require-checker",
"cs-check": "vendor/bin/phpcs",
"cs-fix": "vendor/bin/phpcbf",
"infection": "vendor/bin/infection --threads=4 --min-msi=95 --min-covered-msi=100",
"infection": "vendor/bin/infection --threads=4 --min-msi=100 --min-covered-msi=100",
"infection-xdebug": "@composer run --timeout=0 infection -- --initial-tests-php-options='-d zend_extension=xdebug.so'",
"phpstan": "vendor/bin/phpstan analyse",
"phpunit": "vendor/bin/phpunit",
Expand Down
3 changes: 0 additions & 3 deletions infection.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
"source": {
"directories": [
"src"
],
"excludes": [
"src/ConfigProvider.php"
]
},
"logs": {
Expand Down
45 changes: 42 additions & 3 deletions src/Service/AssetManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,22 @@

namespace Eventjet\AssetManager\Service;

use DateTimeZone;
use Eventjet\AssetManager\Resolver\ResolverInterface;
use Fig\Http\Message\StatusCodeInterface;
use Laminas\Diactoros\Response;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use RuntimeException;
use Safe\DateTimeImmutable;
use Safe\Exceptions\DatetimeException;

use function gmdate;
use function is_string;

use const DATE_RFC7231;

final class AssetManager
{
Expand All @@ -27,16 +37,45 @@ public function resolvesToAsset(RequestInterface $request): bool
return $this->resolver->resolve($request->getUri()->getPath()) !== null;
}

public function buildAssetResponse(RequestInterface $request): ResponseInterface
public function buildAssetResponse(ServerRequestInterface $request): ResponseInterface
{
$asset = $this->resolver->resolve($request->getUri()->getPath());
if ($asset === null) {
throw new RuntimeException(
'Asset could not be resolved. Use "resolvesToAsset" before "buildAssetResponse".'
);
}
return (new Response())
->withStatus(200)
$lastModified = \Safe\filemtime($asset->getPath());
$etagFile = \Safe\md5_file($asset->getPath());

$serverParams = $request->getServerParams();
$ifModifiedSince = $serverParams['HTTP_IF_MODIFIED_SINCE'] ?? null;
$etagHeader = $serverParams['HTTP_IF_NONE_MATCH'] ?? null;

if (is_string($ifModifiedSince)) {
try {
$ifModifiedSince = DateTimeImmutable::createFromFormat(
DATE_RFC7231,
$ifModifiedSince,
new DateTimeZone('UTC')
)->getTimestamp();
} catch (DatetimeException $exception) {
$ifModifiedSince = null;
}
}

$response = (new Response())
->withAddedHeader('Last-Modified', gmdate(DATE_RFC7231, $lastModified))
->withAddedHeader('Etag', $etagFile)
->withAddedHeader('Cache-Control', 'public');

if ($etagHeader === $etagFile || $ifModifiedSince >= $lastModified) {
return $response
->withStatus(StatusCodeInterface::STATUS_NOT_MODIFIED, 'Not Modified');
}

return $response
->withStatus(StatusCodeInterface::STATUS_OK)
->withAddedHeader('Content-Transfer-Encoding', 'binary')
->withAddedHeader('Content-Type', $asset->getMimeType())
->withAddedHeader('Content-Length', $asset->getContentLength())
Expand Down
95 changes: 92 additions & 3 deletions tests/unit/Service/AssetManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
use Eventjet\AssetManager\Service\AssetManager;
use Eventjet\Test\Unit\AssetManager\ObjectFactory;
use Eventjet\Test\Unit\AssetManager\TestDouble\ResolverStub;
use Fig\Http\Message\StatusCodeInterface;
use Laminas\Diactoros\StreamFactory;
use PHPUnit\Framework\TestCase;
use RuntimeException;

use function gmdate;

class AssetManagerTest extends TestCase
{
private ResolverStub $resolver;
Expand All @@ -32,17 +35,103 @@ public function testBuildAssetResponseThrowsExceptionOnUnresolvableAsset(): void
public function testBuildAssetResponseReturnsSuccessResponse(): void
{
$asset = new FileAsset(ObjectFactory::tmpFile('/** js */', 'test.js'));

$this->resolver->setResolvedAsset($asset);

$response = $this->manager->buildAssetResponse(ObjectFactory::serverRequest());

$expectedLastModify = gmdate(DATE_RFC7231, \Safe\filemtime($asset->getPath()));
self::assertSame(200, $response->getStatusCode());
self::assertSame('binary', $response->getHeaders()['Content-Transfer-Encoding'][0]);
self::assertSame('application/javascript', $response->getHeaders()['Content-Type'][0]);
self::assertSame('9', $response->getHeaders()['Content-Length'][0]);
self::assertSame('binary', $response->getHeaderLine('Content-Transfer-Encoding'));
self::assertSame('application/javascript', $response->getHeaderLine('Content-Type'));
self::assertSame('9', $response->getHeaderLine('Content-Length'));
self::assertSame($expectedLastModify, $response->getHeaderLine('Last-Modified'));
self::assertSame(\Safe\md5_file($asset->getPath()), $response->getHeaderLine('Etag'));
self::assertSame('public', $response->getHeaderLine('Cache-Control'));
self::assertSame('/** js */', $response->getBody()->getContents());
}

public function testReturnsNotModifiedIfEtagMatches(): void
{
$asset = new FileAsset(ObjectFactory::tmpFile('/** js */', 'test.js'));
$this->resolver->setResolvedAsset($asset);
$request = ObjectFactory::serverRequest(
null,
null,
['HTTP_IF_NONE_MATCH' => \Safe\md5_file($asset->getPath())]
);

$response = $this->manager->buildAssetResponse($request);

self::assertSame(StatusCodeInterface::STATUS_NOT_MODIFIED, $response->getStatusCode());
}

public function testReturnsNotModifiedIfModifiedSinceMatches(): void
{
$asset = new FileAsset(ObjectFactory::tmpFile('/** js */', 'test.js'));
$this->resolver->setResolvedAsset($asset);
$filemtime = \Safe\filemtime($asset->getPath());
$wantedLastModify = gmdate(DATE_RFC7231, $filemtime);
$request = ObjectFactory::serverRequest(
null,
null,
['HTTP_IF_MODIFIED_SINCE' => $wantedLastModify]
);

$response = $this->manager->buildAssetResponse($request);

self::assertSame(StatusCodeInterface::STATUS_NOT_MODIFIED, $response->getStatusCode());
}

public function testReturnsCompleteAssetIfModifiedSinceIsOlderThanFile(): void
{
$asset = new FileAsset(ObjectFactory::tmpFile('/** js */', 'test.js'));
$this->resolver->setResolvedAsset($asset);
$filemtime = \Safe\filemtime($asset->getPath());
$wantedLastModify = gmdate(DATE_RFC7231, $filemtime - 86400);
$request = ObjectFactory::serverRequest(
null,
null,
['HTTP_IF_MODIFIED_SINCE' => $wantedLastModify]
);

$response = $this->manager->buildAssetResponse($request);

self::assertSame(StatusCodeInterface::STATUS_OK, $response->getStatusCode());
}

public function testReturnsNotModifiedIfModifiedSinceIsNewerThanFile(): void
{
$asset = new FileAsset(ObjectFactory::tmpFile('/** js */', 'test.js'));
$this->resolver->setResolvedAsset($asset);
$filemtime = \Safe\filemtime($asset->getPath());
$wantedLastModify = gmdate(DATE_RFC7231, $filemtime + 86400);
$request = ObjectFactory::serverRequest(
null,
null,
['HTTP_IF_MODIFIED_SINCE' => $wantedLastModify]
);

$response = $this->manager->buildAssetResponse($request);

self::assertSame(StatusCodeInterface::STATUS_NOT_MODIFIED, $response->getStatusCode());
}

public function testInvalidIfNotModifiedSinceHeaderIsIgnored(): void
{
$asset = new FileAsset(ObjectFactory::tmpFile('/** js */', 'test.js'));
$this->resolver->setResolvedAsset($asset);
$request = ObjectFactory::serverRequest(
null,
null,
['HTTP_IF_MODIFIED_SINCE' => 'foo']
);

$response = $this->manager->buildAssetResponse($request);

self::assertSame(StatusCodeInterface::STATUS_OK, $response->getStatusCode());
}

protected function setUp(): void
{
parent::setUp();
Expand Down

0 comments on commit c9443c8

Please sign in to comment.