Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create NamespacedStore to prefix lock resources from symfony/lock #128

Merged
merged 1 commit into from
Sep 12, 2023
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).

## [Unreleased]
### Added
* Add a `NamespaceStore` class that can be used to wrap `symfony/lock` stores and prefix key resources.

### Changed
* *Nothing*

### Deprecated
* *Nothing*

### Removed
* *Nothing*

### Fixed
* *Nothing*


## [5.5.1] - 2023-05-28
### Added
* *Nothing*
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,3 +403,4 @@ $helper->publishUpdate(Update::forTopicAndPayload('some_queue', ['foo' => 'bar']
* `Paginator`: An object extending `Pagerfanta`, that makes it behave as laminas' Paginator object on regards to be able to set `-1` as the max results and get all the results in that case. It requires that you install `pagerfanta/core`.
* `DateRange`: An immutable value object wrapping two `Chronos` date objects that can be used to represent a time period between two dates.
* `IpAddress`: An immutable value object representing an IP address that can be copied into an anonymized instance which removes the last octet.
* `NamespacedStore`: A `symfony/lock` store that can wrap another store instance but making sure keys are prefixed with a namespace and namespace separator.
77 changes: 77 additions & 0 deletions src/Lock/NamespacedStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace Shlinkio\Shlink\Common\Lock;

use Closure;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\SharedLockStoreInterface;

use function sprintf;
use function str_starts_with;

/**
* Wraps a symfony/lock store and prefixes resources with a namespace.
*/
class NamespacedStore implements SharedLockStoreInterface
{
public function __construct(
private readonly SharedLockStoreInterface $wrappedStore,
private readonly ?string $namespace = null,
/** Some stores may not allow default separator value. Make sure you provide the appropriate one */
private readonly string $namespaceSeparator = ':',
) {
}

public function save(Key $key): void
{
$this->wrappedStore->save($this->namespaceKey($key));
}

public function delete(Key $key): void
{
$this->wrappedStore->delete($this->namespaceKey($key));
}

public function exists(Key $key): bool
{
return $this->wrappedStore->exists($this->namespaceKey($key));
}

public function putOffExpiration(Key $key, float $ttl): void
{
$this->wrappedStore->putOffExpiration($this->namespaceKey($key), $ttl);
}

public function saveRead(Key $key): void
{
$this->wrappedStore->saveRead($this->namespaceKey($key));
}

private function namespaceKey(Key $key): Key
{
// If no namespace was provided, just use provided key verbatim
if ($this->namespace === null) {
return $key;
}

// If already prefixed, just use provided key verbatim
$unprefixedResource = $key->__toString();
$prefix = $this->namespace . $this->namespaceSeparator;
if (str_starts_with($unprefixedResource, $prefix)) {
return $key;
}

// Sadly, the key is mutated by wrapped store, and callers take this for granted. Creating a new instance would
// make the reference get detached and things stop working.
// Instead, we need to mutate provided key object to make sure things keep working.
//
// Using Closure::bind we can run a closure using provided key as $this context, and therefore, allowing private
// props to be accessed
return Closure::bind(function (Key $mutableKey) use ($prefix, $unprefixedResource) {
$mutableKey->resource = sprintf('%s%s', $prefix, $unprefixedResource);
return $mutableKey;
}, null, $key)($key);
}
}
54 changes: 54 additions & 0 deletions test/Lock/NamespacedStoreTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace ShlinkioTest\Shlink\Common\Lock;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Lock\NamespacedStore;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\SharedLockStoreInterface;

class NamespacedStoreTest extends TestCase
{
private MockObject & SharedLockStoreInterface $wrappedStore;

public function setUp(): void
{
$this->wrappedStore = $this->createMock(SharedLockStoreInterface::class);
}

#[Test, DataProvider('provideKeysAndNamespaces')]
public function keyIsReturnedVerbatimWhenNoNamespacesIsProvided(
?string $namespace,
Key $key,
string $expectedResource,
): void {
$store = new NamespacedStore($this->wrappedStore, $namespace);
$methods = [
'save' => [$key],
'delete' => [$key],
'exists' => [$key],
'putOffExpiration' => [$key, 123],
'saveRead' => [$key],
];

foreach ($methods as $method => $args) {
$this->wrappedStore->expects($this->once())->method($method)->with(
$this->callback(fn(Key $arg) => $arg->__toString() === $expectedResource),
);

$store->{$method}(...$args);
}
}

public static function provideKeysAndNamespaces(): iterable
{
yield 'no namespace' => [null, new Key($expectedKey = 'base_resource'), $expectedKey];
yield 'namespace already set' => ['shlink', new Key('shlink:base_resource'), 'shlink:base_resource'];
yield 'namespace not set' => ['shlink', new Key('base_resource'), 'shlink:base_resource'];
}
}