Skip to content

Commit

Permalink
Merge pull request #4104 from ushahidi/4073-cache-control-middle
Browse files Browse the repository at this point in the history
feat(cache-control): add middleware for setting cache-control header [WIP]
  • Loading branch information
AmTryingMyBest authored Oct 20, 2020
2 parents 4896c51 + ec041ba commit 96c9aae
Show file tree
Hide file tree
Showing 9 changed files with 297 additions and 2 deletions.
168 changes: 168 additions & 0 deletions app/Http/Middleware/SetCacheHeadersIfAuth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<?php

namespace Ushahidi\App\Http\Middleware;

use Closure;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Contracts\Auth\Factory as Auth;

class SetCacheHeadersIfAuth
{
/**
* The authentication guard factory instance.
*
* @var \Illuminate\Contracts\Auth\Factory
*/
protected $auth;

/**
* Create a new middleware instance.
*
* @param \Illuminate\Contracts\Auth\Factory $auth
* @return void
*/
public function __construct(Auth $auth)
{
$this->auth = $auth;
}

/**
* Check if configuration enables caching for this route.
* Return resolved configuration or null if not enabled.
*/
protected function checkConfig($route_level)
{
// caching levels that we recognize
$levels = [ 'off', 'minimal' ];

// fetch config
$cfg = [
'level' => config('routes.cache_control.level'),
'max_age' => config('routes.cache_control.max_age'),
'private_only' => config('routes.cache_control.private_only'),
];

// translate level tags to numbers
$cfg_level_n = array_search($cfg['level'], $levels);
$r_level_n = array_search($route_level, $levels);
if ($cfg_level_n === false || $r_level_n === false) {
// there's some misconfiguration ...
if ($cfg_level_n === false) {
Log::warn('Unrecognized cache control level in config', [$cfg['level']]);
} else {
Log::warn('Unrecognized cache control level in route config', [$route_level]);
}
// so don't indicate caching
return null;
}

if ($cfg_level_n >= $r_level_n) {
return $cfg;
} else {
return null;
}
}

/**
* Add cache related HTTP headers.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string $level cache level assigned to the route, specify the minimum
* level of caching settings that should activate caching
* behavior for this route.
* See config/api.php for the defined levels
* @param string $ifAuth authentication guard that should be satisfied
* @param string|array $options cache options. Special presets:
* 'preset/dont-cache' -> no-store
* 'preset/default' -> default cache settings as per config
* @return \Symfony\Component\HttpFoundation\Response
*
* @throws \InvalidArgumentException
*/
public function handle($request, Closure $next, $level, $ifAuth = null, $options = [])
{
$response = $next($request);

// If this is not cacheable stuff, bail out
if (! $request->isMethodCacheable() || ! $response->getContent()) {
return $response;
}

// Check config and wether it enables caching
$cfg = $this->checkConfig($level);
if ($cfg == null) {
return $response;
}

// If the cache settings are guarded by auth, check if the auth satisfies
if ($ifAuth != null) {
if ($this->auth->guard($ifAuth)->guest()) {
return $response;
}
}

// Check if options is a preset
if (is_string($options) && strpos(strtolower($options), 'preset/') === 0) {
$preset = strtolower(explode('/', $options)[1] ?? "");
if ($preset === 'dont-cache') {
$options = 'no_store';
} elseif ($preset === 'default') {
$viz = $cfg['private_only'] ? 'private' : 'public';
$maxage = $cfg['max_age'];
$options = [ $viz => true, 'max_age' => "${maxage}" ];
} else {
// Unrecognized preset , don't cache
Log::warn('Unrecognized cache options preset', [$preset]);
return $response;
}
}

if (is_string($options)) {
$options = $this->parseOptions($options);
}

if (isset($options['etag']) && $options['etag'] === true) {
$options['etag'] = md5($response->getContent());
}

if (isset($options['last_modified'])) {
if (is_numeric($options['last_modified'])) {
$options['last_modified'] = Carbon::createFromTimestamp($options['last_modified']);
} else {
$options['last_modified'] = Carbon::parse($options['last_modified']);
}
}

$response->headers->remove('cache-control');
// Still not available in symfony's setCache() method
if (isset($options['no_cache'])) {
$response->headers->set('Cache-Control', 'no-cache');
unset($options['no_cache']);
} elseif (isset($options['no_store'])) {
$response->headers->set('Cache-Control', 'no-store');
unset($options['no_store']);
}
$response->setCache($options);
$response->setVary('Authorization', false);
$response->isNotModified($request);

return $response;
}

/**
* Parse the given header options.
*
* @param string $options
* @return array
*/
protected function parseOptions($options)
{
return collect(explode(';', $options))->mapWithKeys(function ($option) {
$data = explode('=', $option, 2);

return [$data[0] => $data[1] ?? true];
})->all();
}
}
1 change: 1 addition & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public function register()
$this->app->configure('ratelimiter');
$this->app->configure('multisite');
$this->app->configure('ohanzee-db');
$this->app->configure('routes');
$this->app->configure('services');

$this->registerServicesFromAura();
Expand Down
1 change: 1 addition & 0 deletions bootstrap/lumen.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
'signature' => Ushahidi\App\Http\Middleware\SignatureAuth::class,
'feature' => Ushahidi\App\Http\Middleware\CheckFeature::class,
'invalidJSON' => Ushahidi\App\Http\Middleware\CheckForInvalidJSON::class,
'cache.headers.ifAuth' => Ushahidi\App\Http\Middleware\SetCacheHeadersIfAuth::class
]);

/*
Expand Down
41 changes: 41 additions & 0 deletions config/routes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

return [

// Configure cacheability of responses by browsers and intermediate caches
'cache_control' => [
/*
| Preset level of cacheability.
|
| This is a coarse-grained setting to indicate which of all the different
| api endpoints should have cacheable responses.
|
| off - all content is marked as not cacheable
| minimal - allow caching of only the most compute/data-intensive and least
| consistency-critical content. This is usually the global
| geojson endpoint (which may have a lot of points). On top of that,
| caching is only enabled for guest users, not logged-in members.
*/
'level' => env('CACHE_CONTROL_LEVEL', 'off'),

/*
| Longest max-age
|
| This value is applied to what the selected cache control level considers
| the most cacheable responses. Less cacheable responses are assigned a
| proportionally reduced value.
|
| Note that his has no effect if the cache level is set to 'off'
*/
'max_age' => env('CACHE_CONTROL_MAX_AGE', 600),

/*
| Only private caching allowed
|
| Set this to true if you don't want responses to be cached in intermediate
| proxies, but only in the end user's browsers instead.
*/
'private_only' => env('CACHE_CONTROL_PRIVATE', false),

]
];
21 changes: 21 additions & 0 deletions routes/middleware_helpers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

/*
* Utility function to add cache control middleware to a route
*
* It takes a single parameter to specify the minimum level of caching
* settings that should activate caching behavior for this route.
* See config/api.php for the defined levels
*/
if (!function_exists('add_cache_control')) {

function add_cache_control(string $route_level)
{
return [
// These are parsed bottom first, so the default is to cache (if the config allows it)
"cache.headers.ifAuth:{$route_level},api,preset/dont-cache", # applies to api-authenticated requests
"cache.headers.ifAuth:{$route_level},,preset/default",
];
}

}
5 changes: 4 additions & 1 deletion routes/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
$router->get('/{id:[0-9]+}', 'PostsController@show');

// GeoJSON
$router->get('/geojson', 'GeoJSONController@index');
$router->get('/geojson', [
'middleware' => add_cache_control('minimal'),
'uses' => 'GeoJSONController@index'
]);
$router->get('/geojson/{zoom}/{x}/{y}', 'GeoJSONController@index');
$router->get('/{id:[0-9]+}/geojson', 'GeoJSONController@show');

Expand Down
4 changes: 3 additions & 1 deletion routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@
'namespace' => 'API'
], function () use ($router) {

require __DIR__.'/auth.php';
// Load some middleware definitions
require __DIR__.'/middleware_helpers.php';

require __DIR__.'/auth.php';
require __DIR__.'/apikeys.php';
require __DIR__.'/collections.php';
require __DIR__.'/config.php';
Expand Down
37 changes: 37 additions & 0 deletions tests/integration/bootstrap/RestContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
use Aura\Di\Exception;
use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\PyStringNode;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Yaml\Yaml;
use stdClass;

Expand Down Expand Up @@ -71,6 +73,30 @@ public function setDefaultBearerAuth()
$this->thatTheOauthTokenIs('defaulttoken');
}

/**
* @Given /^that cache control level is set to "([^"]*)"$/
*/
public function thatCacheControlLevelIs($levelStr)
{
/*
* FIXME: somehow this is not working effectively. The middleware
* still gets the old values, even when calling config()
* *after* this function runs.
*/
// Set config flag
Config::set('routes.cache_control.level', strtolower($levelStr));
}

/**
* @AfterScenario
* @Given ^that cache control level is set to "([^"]*)"$/
*/
public function disableCacheControlAfterScenario()
{
// Reset config flag to the "off" default
Config::set('routes.cache_control.level', 'off');
}

/**
* @Given /^that I want to make a new "([^"]*)"$/
*/
Expand Down Expand Up @@ -697,6 +723,17 @@ public function theRestHeaderShouldBe($header, $contents)
}
}

/**
* @Then /^the "([^"]*)" header should contain "([^"]*)"$/
*/
public function theRestHeaderShouldContain($header, $contents)
{
$header_val = $this->response->getHeaderLine(strtolower($header));
if (!stripos($header_val, $contents)) {
throw new \Exception('HTTP header ' . $header . ' does not contain '.$contents.
' (actual: '.$header_val.')');
}
}

/**
* @Then /^echo last response$/
Expand Down
21 changes: 21 additions & 0 deletions tests/integration/posts/geojson.feature
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@ Feature: Testing the Posts API
And the "features" property count is "6"
Then the guzzle status code should be 200

# FIXME:
# Commenting this while figuring out how to set the cache control level
# to 'minimal'. This entails changing the Lumen config on the fly, but
# the approach I have tried hasn't worked out yet.
#
# see RestContext.php:thatCacheControlLevelIs()
#
# --
# Scenario: Listing All Posts as GeoJSON with cache-control enabled
# Given that cache control level is set to "minimal"
# Given that I want to get all "Posts"
# When I request "/posts/geojson"
# Then the response is JSON
# And the response has a "type" property
# And the response has a "features" property
# And the "features" property count is "6"
# Then the guzzle status code should be 200
# And the "cache-control" header should exist
# And the "cache-control" header should contain "public"
# And the "cache-control" header should contain "max-age"

Scenario: Find a Post as GeoJSON
Given that I want to find a "Post"
When I request "/posts/1/geojson"
Expand Down

0 comments on commit 96c9aae

Please sign in to comment.