Add ETag to FeedController

This commit is contained in:
Igor Scheller 2023-04-30 12:29:32 +02:00 committed by Michael Weimann
parent dc216a0464
commit 72d48de3ae
5 changed files with 125 additions and 4 deletions

View File

@ -48,6 +48,7 @@ return [
// Changes of request/response parameters // Changes of request/response parameters
\Engelsystem\Middleware\SetLocale::class, \Engelsystem\Middleware\SetLocale::class,
\Engelsystem\Middleware\ETagHandler::class,
\Engelsystem\Middleware\AddHeaders::class, \Engelsystem\Middleware\AddHeaders::class,
// The application code // The application code

View File

@ -33,7 +33,7 @@ class FeedController extends BaseController
{ {
$news = $this->getNews(); $news = $this->getNews();
return $this->response return $this->withEtag($news)
->withHeader('content-type', 'application/atom+xml; charset=utf-8') ->withHeader('content-type', 'application/atom+xml; charset=utf-8')
->withView('api/atom', ['news' => $news]); ->withView('api/atom', ['news' => $news]);
} }
@ -42,7 +42,7 @@ class FeedController extends BaseController
{ {
$news = $this->getNews(); $news = $this->getNews();
return $this->response return $this->withEtag($news)
->withHeader('content-type', 'application/rss+xml; charset=utf-8') ->withHeader('content-type', 'application/rss+xml; charset=utf-8')
->withView('api/rss', ['news' => $news]); ->withView('api/rss', ['news' => $news]);
} }
@ -51,7 +51,7 @@ class FeedController extends BaseController
{ {
$shifts = $this->getShifts(); $shifts = $this->getShifts();
return $this->response return $this->withEtag($shifts)
->withHeader('content-type', 'text/calendar; charset=utf-8') ->withHeader('content-type', 'text/calendar; charset=utf-8')
->withHeader('content-disposition', 'attachment; filename=shifts.ics') ->withHeader('content-disposition', 'attachment; filename=shifts.ics')
->withView('api/ical', ['shiftEntries' => $shifts]); ->withView('api/ical', ['shiftEntries' => $shifts]);
@ -121,11 +121,18 @@ class FeedController extends BaseController
]; ];
} }
return $this->response return $this->withEtag($response)
->withAddedHeader('content-type', 'application/json; charset=utf-8') ->withAddedHeader('content-type', 'application/json; charset=utf-8')
->withContent(json_encode($response)); ->withContent(json_encode($response));
} }
protected function withEtag(mixed $value): Response
{
$hash = md5(json_encode($value));
return $this->response->setEtag($hash);
}
protected function getNews(): Collection protected function getNews(): Collection
{ {
$news = $this->request->has('meetings') $news = $this->request->has('meetings')

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Middleware;
use Illuminate\Support\Str;
use Nyholm\Psr7\Stream;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Symfony\Component\HttpFoundation\Response;
class ETagHandler implements MiddlewareInterface
{
/**
* Compare the response ETag to a requested If-None-Match header and send a 304 "not modified" if they match
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
$etagMatch = $request->getHeader('If-None-Match');
$etag = $response->getHeader('ETag');
if (
!$etagMatch
|| !$etag
|| !Str::contains(implode(', ', $etagMatch), trim($etag[0], '"'))
) {
return $response;
}
return $response
->withStatus(Response::HTTP_NOT_MODIFIED)
->withBody(Stream::create());
}
}

View File

@ -22,6 +22,7 @@ class FeedControllerTest extends ControllerTest
/** /**
* @covers \Engelsystem\Controllers\FeedController::__construct * @covers \Engelsystem\Controllers\FeedController::__construct
* @covers \Engelsystem\Controllers\FeedController::atom * @covers \Engelsystem\Controllers\FeedController::atom
* @covers \Engelsystem\Controllers\FeedController::withEtag
*/ */
public function testAtom(): void public function testAtom(): void
{ {
@ -33,6 +34,12 @@ class FeedControllerTest extends ControllerTest
['content-type', 'application/atom+xml; charset=utf-8'], ['content-type', 'application/atom+xml; charset=utf-8'],
$this->response $this->response
); );
$this->response->expects($this->once())
->method('setEtag')
->willReturnCallback(function ($etag) {
$this->assertNotEmpty($etag);
return $this->response;
});
$this->response->expects($this->once()) $this->response->expects($this->once())
->method('withView') ->method('withView')
->willReturnCallback(function ($view, $data) { ->willReturnCallback(function ($view, $data) {
@ -58,6 +65,7 @@ class FeedControllerTest extends ControllerTest
['content-type', 'application/rss+xml; charset=utf-8'], ['content-type', 'application/rss+xml; charset=utf-8'],
$this->response $this->response
); );
$this->setExpects($this->response, 'setEtag', null, $this->response);
$this->response->expects($this->once()) $this->response->expects($this->once())
->method('withView') ->method('withView')
->willReturnCallback(function ($view, $data) { ->willReturnCallback(function ($view, $data) {
@ -66,6 +74,7 @@ class FeedControllerTest extends ControllerTest
return $this->response; return $this->response;
}); });
$controller->rss(); $controller->rss();
} }
@ -95,6 +104,8 @@ class FeedControllerTest extends ControllerTest
) )
->willReturn($this->response); ->willReturn($this->response);
$this->setExpects($this->response, 'setEtag', null, $this->response);
$this->response->expects($this->once()) $this->response->expects($this->once())
->method('withView') ->method('withView')
->willReturnCallback(function ($view, $data) { ->willReturnCallback(function ($view, $data) {
@ -109,6 +120,7 @@ class FeedControllerTest extends ControllerTest
return $this->response; return $this->response;
}); });
$controller->ical(); $controller->ical();
} }
@ -137,6 +149,8 @@ class FeedControllerTest extends ControllerTest
$this->response $this->response
); );
$this->setExpects($this->response, 'setEtag', null, $this->response);
$this->response->expects($this->once()) $this->response->expects($this->once())
->method('withContent') ->method('withContent')
->willReturnCallback(function ($jsonData) { ->willReturnCallback(function ($jsonData) {
@ -162,6 +176,7 @@ class FeedControllerTest extends ControllerTest
return $this->response; return $this->response;
}); });
$controller->shifts(); $controller->shifts();
} }
@ -184,6 +199,7 @@ class FeedControllerTest extends ControllerTest
$this->request->attributes->set('meetings', $isMeeting); $this->request->attributes->set('meetings', $isMeeting);
$this->setExpects($this->response, 'withHeader', null, $this->response); $this->setExpects($this->response, 'withHeader', null, $this->response);
$this->setExpects($this->response, 'setEtag', null, $this->response);
$this->response->expects($this->once()) $this->response->expects($this->once())
->method('withView') ->method('withView')
->willReturnCallback(function ($view, $data) use ($isMeeting) { ->willReturnCallback(function ($view, $data) use ($isMeeting) {
@ -210,6 +226,7 @@ class FeedControllerTest extends ControllerTest
$controller = new FeedController($this->auth, $this->request, $this->response); $controller = new FeedController($this->auth, $this->request, $this->response);
$this->setExpects($this->response, 'withHeader', null, $this->response); $this->setExpects($this->response, 'withHeader', null, $this->response);
$this->setExpects($this->response, 'setEtag', null, $this->response);
$this->response->expects($this->once()) $this->response->expects($this->once())
->method('withView') ->method('withView')
->willReturnCallback(function ($view, $data) { ->willReturnCallback(function ($view, $data) {

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Test\Unit\Middleware;
use Engelsystem\Http\Request;
use Engelsystem\Http\Response;
use Engelsystem\Middleware\ETagHandler;
use Engelsystem\Test\Unit\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Http\Server\RequestHandlerInterface;
class ETagHandlerTest extends TestCase
{
/**
* @covers \Engelsystem\Middleware\ETagHandler::process
*/
public function testRegister(): void
{
/** @var RequestHandlerInterface|MockObject $handler */
$handler = $this->getMockForAbstractClass(RequestHandlerInterface::class);
$request = Request::create('https://localhost')
->withHeader('If-None-Match', 'FooBarBaz');
$originalResponse = (new Response())
->withHeader('ETag', '"FooBarBaz"')
->withHeader('original-header', 'value')
->withContent('Foo bar!');
$this->setExpects($handler, 'handle', [$request], $originalResponse);
$middleware = new ETagHandler();
$response = $middleware->process($request, $handler);
$this->assertTrue($response->hasHeader('original-header'));
$this->assertEquals('value', $response->getHeader('original-header')[0]);
$this->assertEquals(304, $response->getStatusCode());
$this->assertEquals('', (string) $response->getBody());
}
/**
* @covers \Engelsystem\Middleware\ETagHandler::process
*/
public function testRegisterNoChange(): void
{
/** @var RequestHandlerInterface|MockObject $handler */
$handler = $this->getMockForAbstractClass(RequestHandlerInterface::class);
$request = Request::create('https://localhost');
$originalResponse = new Response();
$this->setExpects($handler, 'handle', [$request], $originalResponse);
$middleware = new ETagHandler();
$response = $middleware->process($request, $handler);
$this->assertEquals($originalResponse, $response);
}
}