Skip to content
This repository has been archived by the owner on Apr 18, 2024. It is now read-only.

Adds support for Akamai purging #51

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ If you are looking additional integrations (Cache Drivers), feel free to contrib
* [KeyCDN](https://www.keycdn.com) (CDN/SaaS)
* [Fastly](https://www.fastly.com) (CDN/SaaS)
* [Cloudflare](https://www.cloudflare.com) (CDN/SaaS)
* [Akamai](https://www.akamai.com) (CDN/SaaS)
* Varnish with XKEY support (your own proxy)
* Dummy (does nothing)

Expand Down Expand Up @@ -80,6 +81,17 @@ UPPER_DRIVER=varnish
VARNISH_URL=<REPLACE-ME>
```

### Akamai Setup

```
UPPER_DRIVER=akamai
AKAMAI_HOST=<REPLACE-ME>
AKAMAI_CLIENT_TOKEN=<REPLACE-ME>
AKAMAI_CLIENT_SECRET=<REPLACE-ME>
AKAMAI_ACCESS_TOKEN=<REPLACE-ME>
AKAMAI_MAX_SIZE=2048
```

### Tuning

With `Cache-Control` headers you can disabled caching for certain templates:
Expand Down
6 changes: 4 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"cache",
"cloudflare",
"fastly",
"keycdn"
"keycdn",
"akamai"
],
"support": {
"docs": "https://github.com/ostark/upper/blob/master/README.md",
Expand All @@ -25,7 +26,8 @@
],
"require": {
"craftcms/cms": "^3.2.0",
"guzzlehttp/guzzle": "^6.5.5|^7.2.0"
"guzzlehttp/guzzle": "^6.5.5|^7.2.0",
"akamai-open/edgegrid-auth": "^1.0"
},
"require-dev": {
"vimeo/psalm": "^4.4"
Expand Down
22 changes: 10 additions & 12 deletions src/EventRegistrar.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<?php namespace ostark\upper;
<?php

namespace ostark\upper;

use craft\base\Element;
use craft\elements\db\ElementQuery;
Expand Down Expand Up @@ -46,7 +48,6 @@ public static function registerUpdateEvents()
Event::on(Sections::class, Sections::EVENT_AFTER_SAVE_SECTION, function ($event) {
static::handleUpdateEvent($event);
});

}

public static function registerFrontendEvents()
Expand All @@ -60,7 +61,8 @@ public static function registerFrontendEvents()
$request = \Craft::$app->getRequest();

// Don't cache CP, LivePreview, Action, Non-GET requests
if ($request->getIsCpRequest() ||
if (
$request->getIsCpRequest() ||
$request->getIsLivePreview() ||
$request->getIsActionRequest() ||
!$request->getIsGet()
Expand All @@ -87,7 +89,6 @@ public static function registerFrontendEvents()

// Add to collection
Plugin::getInstance()->getTagCollection()->addTagsFromElement($event->row);

});

// Add the tags to the response header
Expand Down Expand Up @@ -118,15 +119,15 @@ public static function registerFrontendEvents()
$response->setSharedMaxAge($maxAge);
$headers->set(Plugin::INFO_HEADER_NAME, "CACHED: " . date(\DateTime::ISO8601));

$plugin->trigger($plugin::EVENT_AFTER_SET_TAG_HEADER, new CacheResponseEvent([
$plugin->trigger($plugin::EVENT_AFTER_SET_TAG_HEADER, new CacheResponseEvent(
[
'tags' => $tags,
'maxAge' => $maxAge,
'requestUrl' => \Craft::$app->getRequest()->getUrl(),
'headers' => $response->getHeaders()->toArray()
]
));
});

}


Expand Down Expand Up @@ -172,7 +173,7 @@ public static function registerFallback()
// Insert item
\Craft::$app->getDb()->createCommand()
->upsert(
// Table
// Table
Plugin::CACHE_TABLE,

// Identifier
Expand All @@ -191,9 +192,7 @@ public static function registerFallback()
} catch (\Exception $e) {
\Craft::warning("Failed to register fallback.", "upper");
}

});

}


Expand Down Expand Up @@ -257,14 +256,13 @@ protected static function handleUpdateEvent(Event $event)
Plugin::getInstance()->trigger(Plugin::EVENT_BEFORE_PURGE, $purgeEvent);

// Push to queue
\Craft::$app->getQueue()->push(new PurgeCacheJob([
\Craft::$app->getQueue()->ttr(1200)->push(new PurgeCacheJob(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any good reason for ttr(1200)?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In our experience, with sites that have a lot of URLs the purge by url loop takes too long and times out with the default TTR. We actually purge by tag now to avoid this problem, so I guess the ttr might indeed be overkill in most cases.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Hyra Let's not hardcode this. Either make it a config option, or just remove it and users can control it with EVENT_BEFORE_PUSH.

[
'tag' => $purgeEvent->tag
]
));

Plugin::getInstance()->trigger(Plugin::EVENT_AFTER_PURGE, $purgeEvent);
}

}

}
11 changes: 11 additions & 0 deletions src/config.example.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php

/**
* Don't edit the config.example.php.
* Instead modify the projects/config/upper.php and use ENV VARS
Expand Down Expand Up @@ -62,6 +63,16 @@
'apiEmail' => getenv('CLOUDFLARE_API_EMAIL'),
],

// Akamai config
'akamai' => [
'tagHeaderName' => 'Edge-Cache-Tag',
'host' => getenv('AKAMAI_HOST'),
'clientToken' => getenv('AKAMAI_CLIENT_TOKEN'),
'clientSecret' => getenv('AKAMAI_CLIENT_SECRET'),
'accessToken' => getenv('AKAMAI_ACCESS_TOKEN'),
'maxSize' => getenv('AKAMAI_MAX_SIZE'),
],

// Dummy driver (default)
'dummy' => [
'tagHeaderName' => 'X-CacheTag',
Expand Down
150 changes: 150 additions & 0 deletions src/drivers/Akamai.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php

namespace ostark\upper\drivers;

use GuzzleHttp\Exception\BadResponseException;
use ostark\upper\exceptions\AkamaiApiException;

/**
* Class Akamai Driver
*
* @package ostark\upper\drivers
*
*/
class Akamai extends AbstractPurger implements CachePurgeInterface
{
/**
* @var string
*/
public $host;

/**
* @var string
*/
public $clientToken;

/**
* @var string
*/
public $clientSecret;

/**
* @var string
*/
public $accessToken;

/**
* @var string
*/
public $maxSize;

/**
* Purge cache by tag
*
* @param string $tag
*
* @return bool
*/
public function purgeTag(string $tag)
{
if ($this->useLocalTags) {
return $this->purgeUrlsByTag($tag);
}

$this->sendRequest('production', 'POST', 'tag', $tag);
$this->sendRequest('staging', 'POST', 'tag', $tag);
ostark marked this conversation as resolved.
Show resolved Hide resolved

return true;
}

/**
* Purge cache by urls
*
* @param array $urls
*
* @return bool
*/
public function purgeUrls(array $urls)
{
foreach ($urls as $url) {
if (!$this->sendRequest('production', 'POST', 'url', getenv('DEFAULT_SITE_URL') . $url)) {
return false;
}
if (!$this->sendRequest('staging', 'POST', 'url', getenv('DEFAULT_SITE_URL') . $url)) {
return false;
}
}

return true;
}


/**
* Purge entire cache
*
* @return bool
*/
public function purgeAll()
{
// TODO: Purge all in Akamai
return true;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there no endpoint?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, no. Only by tag, cp code or url. CP Code could work as a purge all, but only if you set it up specifically.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a quick look at the docs. cp code should work.

// return $this->sendRequest('POST', 'purge_all');
}

/**
* Send API call
*
* @param string $method HTTP verb
* @param string $type of purge
* @param string $uri
* @param array $headers
*
* @return bool
* @throws \ostark\upper\exceptions\AkamaiApiException
*/
protected function sendRequest(string $environment = 'production', string $method = 'POST', string $type = "url", string $uri = "", array $headers = [])
{
// Akamai Open Edgegrid reads $_ENV which doesn't get populated by Craft, so filling in the blanks
$_ENV['AKAMAI_HOST'] = getenv('AKAMAI_HOST');
$_ENV['AKAMAI_CLIENT_TOKEN'] = getenv('AKAMAI_CLIENT_TOKEN');
$_ENV['AKAMAI_CLIENT_SECRET'] = getenv('AKAMAI_CLIENT_SECRET');
$_ENV['AKAMAI_ACCESS_TOKEN'] = getenv('AKAMAI_ACCESS_TOKEN');
$_ENV['AKAMAI_MAX_SIZE'] = getenv('AKAMAI_MAX_SIZE');

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These values should be coming from config, not getenv, right?

$auth = \Akamai\Open\EdgeGrid\Authentication::createFromEnv();

$auth->setHttpMethod('POST');
$auth->setPath('/ccu/v3/invalidate/' . $type . '/' . $environment);

$body = json_encode(array(
'objects' => array($uri)
));

$auth->setBody($body);

$context = array(
'http' => array(
'header' => array(
'Authorization: ' . $auth->createAuthHeader(),
'Content-Type: application/json',
'Content-Length: ' . strlen($body),
),
'method' => 'POST',
'content' => $body
)
);

$context = stream_context_create($context);

try {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is nothing wrong with file_get_contents(), but to be consistent I'd prefer GuzzleHttp\Client

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough, I'll change it ! 👍🏻

json_decode(file_get_contents('https://' . $auth->getHost() . $auth->getPath(), false, $context));
} catch (BadResponseException $e) {
throw AkamaiApiException::create(
$e->getRequest(),
$e->getResponse()
);
}

return true;
}
}
44 changes: 44 additions & 0 deletions src/exceptions/AkamaiApiException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace ostark\upper\exceptions;

use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

class AkamaiApiException extends \Exception
{
public function __construct(string $message = "", int $code = 0, \Throwable $previous = null)
{
parent::__construct("Akamai API error: $message", $code, $previous);
}

/**
* @param \Psr\Http\Message\RequestInterface $request
* @param \Psr\Http\Message\ResponseInterface|null $response
*
* @return static
*/
public static function create(RequestInterface $request, ResponseInterface $response = null)
{
$uri = $request->getUri();

if (is_null($response)) {
return new static("Akamai no response error, uri: '$uri'");
}

// Extract error message from body
$status = $response->getStatusCode();
$json = json_decode($response->getBody());
if (json_last_error() !== JSON_ERROR_NONE) {
return new static("Akamai API error ($status) on: '$uri'", $status);
}

// Error message
if (isset($json->msg)) {
return new static($json->msg . ", uri: '$uri'", $response->getStatusCode());
}

// Unknown
return new static("Unknown error, uri: '$uri'");
}
}