Skip to content
Open
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
21 changes: 20 additions & 1 deletion src/StaticCaching/Cachers/AbstractCacher.php
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,15 @@ public function refreshUrl($url, $domain = null)
{
$this->getUrls($domain)->filter(function ($value) use ($url) {
return $value === $url || Str::startsWith($value, $url.'?');
})->each(function ($url) use ($domain) {
})->each(function ($url, $key) use ($domain) {
// Warming an error response would just fail with the same error,
// so invalidate it and let the next request cache a fresh copy.
if ($this->hasCachedErrorResponse($key)) {
$this->invalidateUrl($url, $domain);

return;
}

$url = ($domain ?: $this->getBaseUrl()).$url;

$url = RecacheToken::addToUrl($url);
Expand All @@ -275,6 +283,17 @@ public function refreshUrl($url, $domain = null)
});
}

/**
* Check if the cached response for a URL key is an error response.
*
* @param string $key
* @return bool
*/
protected function hasCachedErrorResponse($key)
{
return false;
}

/**
* Refresh a wildcard URL.
*
Expand Down
31 changes: 30 additions & 1 deletion src/StaticCaching/Cachers/ApplicationCacher.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,19 @@ public function cachePage(Request $request, $content)
$key = $this->normalizeKey('responses:'.$key);
$value = $this->normalizeContent($content);

Event::listen(ResponsePrepared::class, function (ResponsePrepared $event) use ($key, $value) {
// The listener stays registered for the lifetime of the process, so it should
// only handle the response for the request that's currently being cached.
// Otherwise, in long-running processes (e.g. Octane, tests) it would
// re-store this entry using later requests' statuses and headers.
$handled = false;

Event::listen(ResponsePrepared::class, function (ResponsePrepared $event) use ($key, $value, &$handled) {
if ($handled) {
return;
}

$handled = true;

$headers = collect($event->response->headers->all())
->reject(fn ($value, $key) => in_array($key, ['date', 'x-powered-by', 'cache-control', 'expires', 'set-cookie']))
->all();
Expand Down Expand Up @@ -92,6 +104,23 @@ private function getFromCache(Request $request)
return $this->cache->get($this->normalizeKey('responses:'.$key));
}

/**
* Check if the cached response for a URL key is an error response.
*
* @param string $key
* @return bool
*/
protected function hasCachedErrorResponse($key)
{
$cached = $this->cache->get($this->normalizeKey('responses:'.$key));

if (! is_array($cached)) {
return false;
}

return ($cached['status'] ?? 200) >= 400;
}

/**
* Flush out the entire static cache.
*
Expand Down
113 changes: 113 additions & 0 deletions tests/StaticCaching/ApplicationCacherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@

use Illuminate\Contracts\Cache\Repository;
use Illuminate\Http\Request;
use Illuminate\Routing\Events\ResponsePrepared;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Console\Commands\StaticWarmJob;
use Statamic\Events\UrlInvalidated;
use Statamic\StaticCaching\Cacher;
use Statamic\StaticCaching\Cachers\ApplicationCacher;
Expand Down Expand Up @@ -67,6 +70,82 @@ public function checking_if_page_is_cached_then_retrieving_it_will_only_hit_the_
$this->assertEquals('application/html', $cachedPage->headers['Content-Type']);
}

#[Test]
#[DataProvider('cachedResponseProvider')]
public function caching_a_page_tracks_the_url($status, $content)
{
$cache = app(Repository::class);
$cacher = new ApplicationCacher($cache, ['base_url' => 'http://example.com']);
$request = Request::create('http://example.com/about', 'GET');
$response = response($content, $status);

$cacher->cachePage($request, $response);
event(new ResponsePrepared($request, $response));

$this->assertEquals(['/about'], $cacher->getUrls()->values()->all());
$this->assertTrue($cacher->hasCachedPage($request));
$this->assertEquals($content, $cacher->getCachedPage($request)->content);
}

public static function cachedResponseProvider()
{
return [
'successful response' => [200, 'about page'],
'error response' => [404, 'not found'],
];
}

#[Test]
public function refreshing_a_wildcard_warms_successful_urls_and_invalidates_error_urls()
{
Queue::fake();

$cache = app(Repository::class);
$cacher = new ApplicationCacher($cache, ['base_url' => 'http://example.com']);

$goodRequest = Request::create('http://example.com/rail/one', 'GET');
$cacher->cachePage($goodRequest, response('one', 200));
event(new ResponsePrepared($goodRequest, response('one', 200)));

$junkRequest = Request::create('http://example.com/rail/scanner-junk', 'GET');
$cacher->cachePage($junkRequest, response('not found', 404));
event(new ResponsePrepared($junkRequest, response('not found', 404)));

$cacher->refreshUrls(['/rail/*']);

Queue::assertPushed(StaticWarmJob::class, function ($job) {
return str_contains((string) $job->request->getUri(), '/rail/one');
});
Queue::assertNotPushed(StaticWarmJob::class, function ($job) {
return str_contains((string) $job->request->getUri(), 'scanner-junk');
});

// The error response is invalidated rather than warmed, so the
// tracked set converges to real pages.
$this->assertEquals(['/rail/one'], $cacher->getUrls()->values()->all());
$this->assertFalse($cacher->hasCachedPage($junkRequest));
}

#[Test]
public function refreshing_an_error_url_invalidates_it_instead_of_warming_it()
{
Queue::fake();

$cache = app(Repository::class);
$cacher = new ApplicationCacher($cache, ['base_url' => 'http://example.com']);
$request = Request::create('http://example.com/foo', 'GET');
$response = response('not found', 404);

$cacher->cachePage($request, $response);
event(new ResponsePrepared($request, $response));

$cacher->refreshUrls(['/foo']);

Queue::assertNothingPushed();
$this->assertEquals([], $cacher->getUrls()->all());
$this->assertFalse($cacher->hasCachedPage($request));
}

#[Test]
public function invalidating_a_url_removes_the_html_and_the_url()
{
Expand All @@ -92,6 +171,23 @@ public function invalidating_a_url_removes_the_html_and_the_url()
$this->assertNotNull($cache->get('static-cache:responses:two'));
}

#[Test]
public function invalidating_a_url_removes_a_cached_error_response()
{
$cache = app(Repository::class);
$cacher = new ApplicationCacher($cache, ['base_url' => 'http://example.com']);
$request = Request::create('http://example.com/foo', 'GET');
$response = response('not found', 404);

$cacher->cachePage($request, $response);
event(new ResponsePrepared($request, $response));

$cacher->invalidateUrl('/foo');

$this->assertEquals([], $cacher->getUrls()->all());
$this->assertFalse($cacher->hasCachedPage($request));
}

#[Test]
public function invalidating_a_url_will_invalidate_all_query_string_versions_too()
{
Expand Down Expand Up @@ -232,6 +328,23 @@ public function it_flushes()
$this->assertEquals([], $cacher->getUrls('http://another.com')->all());
}

#[Test]
public function flushing_removes_cached_error_responses()
{
$cache = app(Repository::class);
$cacher = new ApplicationCacher($cache, ['base_url' => 'http://example.com']);
$request = Request::create('http://example.com/foo', 'GET');
$response = response('not found', 404);

$cacher->cachePage($request, $response);
event(new ResponsePrepared($request, $response));

$cacher->flush();

$this->assertEquals([], $cacher->getUrls()->all());
$this->assertFalse($cacher->hasCachedPage($request));
}

#[Test]
#[DataProvider('currentUrlProvider')]
public function it_gets_the_current_url(
Expand Down
74 changes: 74 additions & 0 deletions tests/StaticCaching/HalfMeasureStaticCachingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
namespace Tests\StaticCaching;

use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
use Orchestra\Testbench\Attributes\DefineEnvironment;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Console\Commands\StaticWarmJob;
use Statamic\StaticCaching\Cacher;
use Statamic\StaticCaching\Replacer;
use Symfony\Component\HttpFoundation\Response;
use Tests\FakesContent;
Expand Down Expand Up @@ -205,6 +208,77 @@ public function nocache_session_is_written_under_the_real_url_for_shared_errors(
);
}

#[Test]
public function it_caches_and_tracks_404s()
{
\Illuminate\Support\Facades\Cache::flush();

$this->withStandardFakeViews();
$this->viewShouldReturnRaw('errors.404', '404 not found');

$this->get('/this-does-not-exist')->assertNotFound();

$this->assertEquals(['/this-does-not-exist'], app(Cacher::class)->getUrls()->values()->all());

$response = $this->get('/this-does-not-exist')->assertNotFound();
$this->assertTrue($response->wasStaticallyCached());
}

#[Test]
public function invalidating_a_cached_404_lets_new_content_be_served()
{
\Illuminate\Support\Facades\Cache::flush();

$this->withStandardFakeViews();
$this->viewShouldReturnRaw('default', '{{ title }}');
$this->viewShouldReturnRaw('errors.404', '404 not found');

// The URL 404s before the page exists, and the 404 gets cached.
$this->get('/about')->assertNotFound();

// Publishing a page at that URL invalidates the cached 404...
$this->createPage('about', ['with' => ['title' => 'The About Page']]);
app(Cacher::class)->invalidateUrls(['/about']);

// ...so the new page is served instead of the stale 404.
$this->get('/about')->assertOk()->assertSee('The About Page');
}

#[Test]
public function wildcard_refresh_invalidates_cached_404s_instead_of_warming_them()
{
\Illuminate\Support\Facades\Cache::flush();

Queue::fake();

$this->withStandardFakeViews();
$this->viewShouldReturnRaw('default', '{{ title }}');
$this->viewShouldReturnRaw('errors.404', '404 not found');

$this->createPage('about', ['with' => ['title' => 'The About Page']]);

// A real page, matching the wildcard `/about*` rule below.
$this->get('/about')->assertOk();

// A junk URL that also matches the wildcard prefix, but doesn't resolve
// to real content (e.g. a bot/scanner probe under the same path).
$this->get('/about-this-does-not-exist')->assertNotFound();

app(Cacher::class)->refreshUrls(['/about*']);

Queue::assertPushed(StaticWarmJob::class, function ($job) {
return str_contains((string) $job->request->getUri(), '/about')
&& ! str_contains((string) $job->request->getUri(), 'this-does-not-exist');
});
Queue::assertNotPushed(StaticWarmJob::class, function ($job) {
return str_contains((string) $job->request->getUri(), 'this-does-not-exist');
});

// The junk URL is invalidated rather than warmed, so the tracked
// set converges to real pages.
$this->assertEquals(['/about'], app(Cacher::class)->getUrls()->values()->all());
}

#[Test]
public function it_can_keep_parts_dynamic_using_nocache_tags_in_loops()
{
Expand Down
Loading