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

Commit

Permalink
Merge d2552d8 into e57dc97
Browse files Browse the repository at this point in the history
  • Loading branch information
kleijnweb committed Feb 21, 2016
2 parents e57dc97 + d2552d8 commit 3b1b1f4
Show file tree
Hide file tree
Showing 12 changed files with 731 additions and 68 deletions.
41 changes: 37 additions & 4 deletions README.md
Expand Up @@ -16,11 +16,30 @@ RestETagBundle uses REST semantics to form a cache invalidation and optimistic c

* Versions the resources your URI paths represents and keeps this list in a server side cache.
* Increments the version of a path when one of the following methods is used: POST, PUT, PATCH, DELETE
* Increments the version of all parent paths when that of a child in incremented
* Increments the version of all parent and selected "lower" paths when that of a child in incremented
* Ensures the tag passed using If-Match matches the ETag in the cache, returns HTTP 412 in case of discrepancy.
* Returns HTTP 428 responses when concurrency control is enabled and the appropriate header is missing.

The bundle uses microtime based version IDs to prevent loss of the server side cache causing collisions and sub-second resource locking.
The bundle uses microtime based version IDs to prevent loss of the server side cache causing collisions and sub-second resource locking. Removes all non-printable and non-ascii chars from URLs before using them as cache keys.

The versioning scheme is pretty straightforward, examples:

* Modifying `/animals/rabbits/1`: invalidates `/animals`, `/animals/rabbits`, `/animals/rabbits/1`, and (if it exists) `/animals/rabbits/1/relations/owners`
* GET on `/animals/rabbits/2`: this is not effected by the previous example. In addition, this will create a version if none exists yet (without invalidating anything)
* Modifying `/animals/rabbits`: both `/animals` and `/animals/rabbits` get a new version.
So will any existing versions matching the child invalidation constraint (see configuration), eg `/animals/rabbits/findByName`

The query part of the URL is treated as the last path segment:

* Modifying `/animals?type=rabbits`: will be interpreted as modification of `/animals/?type=rabbits`. So `/animals` will be invalidated.
* GET on `/animals?type=rabbits`: will be interpreted as GET `/animals/?type=rabbits`.
* Modifying `/animals/rabbits?id=1`: will be interpreted as a modification of `/animals/rabbits/?id=1`. So the old versions of both `/animals` and `/animals/rabbits` are invalidated too.
* GET on `/animals?type=dogs`: will be interpreted as GET `/animals/?type=dogs`. So a modification of `/animals?type=rabbits` will not affect it (but modification of `/animals` will invalidate it).

The default child invalidation constraint is a negated regular expression: `\/[0-9]+$`. This means a POST to `/animals/rabbits` will by default not invalidate `/animals/rabbits/1` or any paths below it, but will invalidate `/animals/rabbits/findByName`.

*NOTE:* When concurrency control is turned on, you cannot POST to without the correct E-Tag either.
*NOTE:* The store and retrieve calls are not yet fully optimized and get pretty chatty when using network based caches. You can probably expect best performance from APCu. It won't use that much memory.

## Install And Configure

Expand All @@ -30,15 +49,29 @@ Concurrency control is enabled by default. To disable:

```yml
rest_e_tags:
concurrency_control: false
concurrency_control: false
```

The bundle will work with any Doctrine cache. Use the 'cache' config option to reference the service to be used:

```yml
rest_e_tags:
cache: my.doctrine.cache
cache: my.doctrine.cache
```

You can tweak the default child invalidation constraint (negated, see default above):

```yml
rest_e_tags:
# Do not invalidate paths that look like they end in UUIDs (nor any paths below them)
child_invalidation_constraint: '\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
```

```yml
rest_e_tags:
# Always invalidate, skip regex match
child_invalidation_constraint: ''
```
## License

KleijnWeb\RestETagBundle is made available under the terms of the [LGPL, version 3.0](https://spdx.org/licenses/LGPL-3.0.html#licenseText).
212 changes: 212 additions & 0 deletions src/Cache/CacheAdapter.php
@@ -0,0 +1,212 @@
<?php
/*
* This file is part of the KleijnWeb\RestETagBundle package.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace KleijnWeb\RestETagBundle\Cache;

use Doctrine\Common\Cache\Cache;
use Symfony\Component\HttpFoundation\Request;

/**
* @author John Kleijn <john@kleijnweb.nl>
*/
class CacheAdapter
{
const KEY_VERSION = 1;
const KEY_CHILDREN = 2;

/**
* @var Cache
*/
private $cache;

/**
* @var string
*/
private $childInvalidationConstraint;

/**
* @param Cache $cache
* @param string $childInvalidationConstraint
*/
public function __construct(Cache $cache, $childInvalidationConstraint = '')
{
$this->cache = $cache;
$this->childInvalidationConstraint = $childInvalidationConstraint;
}

/**
* @param string $childInvalidationConstraint
*
* @return $this
*/
public function setChildInvalidationConstraint($childInvalidationConstraint)
{
$this->childInvalidationConstraint = $childInvalidationConstraint;

return $this;
}

/**
* @param Request $request
*
* @return string
*/
public function fetch(Request $request)
{
if (!$record = $this->cache->fetch($this->createKey($request))) {
return '';
}

return $record[self::KEY_VERSION];
}

/**
* @param Request $request
*
* @return bool
*/
public function contains(Request $request)
{
return $this->containsKey($this->createKey($request));
}

/**
* @param string $key
*
* @return bool
*/
public function containsKey($key)
{
return $this->cache->contains($key);
}

/**
* @param Request $request
* @param string $version
*
* @return mixed
*/
public function update(Request $request, $version)
{
$segments = $this->getSegments($request);
$paths = [];
$path = '';
foreach ($segments as $segment) {
$path .= "/$segment";
$paths[] = $path;
}

foreach ($paths as $i => $path) {
$record = $this->cache->fetch($path);
if ($record) {
$this->invalidateChildren($record[self::KEY_CHILDREN], $version);
} else {
$record = [self::KEY_CHILDREN => []];
}
$record[self::KEY_VERSION] = $version;
if (isset($paths[$i + 1])) {
$record[self::KEY_CHILDREN][] = $paths[$i + 1];
}
$this->cache->save($path, $record);
}

return $version;
}

/**
* @param Request $request
* @param string $version
*
* @return mixed
*/
public function register(Request $request, $version)
{
$segments = $this->getSegments($request);
$paths = [];
$path = '';
foreach ($segments as $segment) {
$path .= "/$segment";
$paths[] = $path;
}

foreach ($paths as $i => $path) {
$record = $this->cache->fetch($path);
if (!$record) {
$record = [self::KEY_VERSION => $version, self::KEY_CHILDREN => []];
}
if (isset($paths[$i + 1])) {
$record[self::KEY_CHILDREN][] = $paths[$i + 1];
}
$this->cache->save($path, $record);
}
$record = [self::KEY_VERSION => $version, self::KEY_CHILDREN => []];
$this->cache->save($this->createKeyFromSegments($segments), $record);

return $version;
}

private function invalidateChildren(array $children, $version)
{
foreach ($children as $child) {
if ($this->childInvalidationConstraint !== ''
&& preg_match("/$this->childInvalidationConstraint/", $child)
) {
// Stop recursive invalidation if it matches
return;
}
$record = $this->cache->fetch($child);
$record[self::KEY_VERSION] = $version;
$this->cache->save($child, $record);
if ($record) {
$this->invalidateChildren($record[self::KEY_CHILDREN], $version);
}
}
}

/**
* @param Request $request
*
* @return array
*/
private function getSegments(Request $request)
{
$key = $request->getPathInfo();
$segments = explode('/', ltrim($key, '/'));
if ($query = $request->getQueryString()) {
$segments[] = '?' . $query;
}

array_walk($segments, function (&$value) {
$value = preg_replace('/[^[:print:]]/', '_', $value);
});

return array_filter($segments, function ($value) {
return $value !== '';
});
}

/**
* @param Request $request
*
* @return string
*/
private function createKey(Request $request)
{
return $this->createKeyFromSegments($this->getSegments($request));
}

/**
* @param array $segments
*
* @return string
*/
private function createKeyFromSegments(array $segments)
{
return '/' . implode('/', $segments);
}
}
3 changes: 2 additions & 1 deletion src/DependencyInjection/Configuration.php
Expand Up @@ -27,7 +27,8 @@ public function getConfigTreeBuilder()
$rootNode
->children()
->booleanNode('concurrency_control')->defaultFalse()->end()
->scalarNode('cache')->defaultFalse()->end()
->scalarNode('child_invalidation_constraint')->defaultValue('\/[0-9]+$')->end()
->scalarNode('cache')->isRequired()->defaultFalse()->end()
;

return $treeBuilder;
Expand Down
1 change: 1 addition & 0 deletions src/DependencyInjection/KleijnWebRestETagExtension.php
Expand Up @@ -27,6 +27,7 @@ public function load(array $configs, ContainerBuilder $container)
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
$loader->load('services.yml');
$container->setParameter('rest_e_tags.concurrency_control', $config['concurrency_control']);
$container->setParameter('rest_e_tags.child_invalidation_constraint', $config['child_invalidation_constraint']);
$container->setAlias('rest_e_tags.cache', $config['cache']);
}

Expand Down
48 changes: 38 additions & 10 deletions src/EventListener/RequestListener.php
Expand Up @@ -8,7 +8,7 @@

namespace KleijnWeb\RestETagBundle\EventListener;

use Doctrine\Common\Cache\Cache;
use KleijnWeb\RestETagBundle\Cache\CacheAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
Expand All @@ -19,22 +19,22 @@
class RequestListener
{
/**
* @var Cache
* @var CacheAdapter
*/
private $cache;
private $cacheAdapter;

/**
* @var bool
*/
private $concurrencyControl;

/**
* @param Cache $cache
* @param bool $concurrencyControl
* @param CacheAdapter $cache
* @param bool $concurrencyControl
*/
public function __construct(Cache $cache, $concurrencyControl = true)
public function __construct(CacheAdapter $cache, $concurrencyControl = true)
{
$this->cache = $cache;
$this->cacheAdapter = $cache;
$this->concurrencyControl = $concurrencyControl;
}

Expand All @@ -57,21 +57,49 @@ public function onKernelRequest(GetResponseEvent $event)
*
* @return bool
*/
public static function isModifyingRequest(Request $request)
public static function isModifyingMethodRequest(Request $request)
{
$method = strtoupper($request->getMethod());

return in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE']);
}

/**
* @param Request $request
*
* @return bool
*/
public static function isIgnoreMethodRequest(Request $request)
{
$method = strtoupper($request->getMethod());

return in_array($method, ['OPTIONS', 'HEAD']);
}

/**
* @param Request $request
*
* @return bool
*/
public static function isSupportedMethodRequest(Request $request)
{
$method = strtoupper($request->getMethod());

return in_array($method, ['GET', 'OPTIONS', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']);
}

/**
* @param Request $request
*
* @return null|Response
*/
private function createResponse(Request $request)
{
if (!$version = $this->cache->fetch($request->getPathInfo())) {
if (!self::isSupportedMethodRequest($request)) {
return new Response('', Response::HTTP_METHOD_NOT_ALLOWED);
}

if (!$version = $this->cacheAdapter->fetch($request)) {
return null;
}
$method = strtoupper($request->getMethod());
Expand All @@ -81,7 +109,7 @@ private function createResponse(Request $request)
if ($ifNoneMatch && $version === $ifNoneMatch) {
return new Response('', Response::HTTP_NOT_MODIFIED);
}
} elseif ($this->concurrencyControl && self::isModifyingRequest($request)) {
} elseif ($this->concurrencyControl && self::isModifyingMethodRequest($request)) {
$ifMatch = $request->headers->get('If-Match');
if (!$ifMatch) {
return new Response('', Response::HTTP_PRECONDITION_REQUIRED);
Expand Down

0 comments on commit 3b1b1f4

Please sign in to comment.