diff --git a/composer.json b/composer.json index f14cf11..c6cc7b1 100644 --- a/composer.json +++ b/composer.json @@ -10,8 +10,8 @@ "require": { "php": "^7.0", "api-clients/middleware": "^1.0", - "guzzlehttp/psr7": "^1.3", - "react/cache": "^0.4.1" + "react/cache": "^0.4.1", + "ringcentral/psr7": "^1.2" }, "require-dev": { "api-clients/test-utilities": "^2.0" diff --git a/composer.lock b/composer.lock index 2f8a050..a98481c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "2ba6f05340cce89b6093d1a822ac278b", - "content-hash": "2b5982ac19a862966c69079b8f7d16c1", + "hash": "62018b0f3d0d5809054c17c9df1235c8", + "content-hash": "86fb7c110e642134d0ee668b46439403", "packages": [ { "name": "api-clients/middleware", @@ -54,64 +54,6 @@ "description": "Request middleware", "time": "2016-12-05 07:52:20" }, - { - "name": "guzzlehttp/psr7", - "version": "1.3.1", - "source": { - "type": "git", - "url": "https://github.com/guzzle/psr7.git", - "reference": "5c6447c9df362e8f8093bda8f5d8873fe5c7f65b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/5c6447c9df362e8f8093bda8f5d8873fe5c7f65b", - "reference": "5c6447c9df362e8f8093bda8f5d8873fe5c7f65b", - "shasum": "" - }, - "require": { - "php": ">=5.4.0", - "psr/http-message": "~1.0" - }, - "provide": { - "psr/http-message-implementation": "1.0" - }, - "require-dev": { - "phpunit/phpunit": "~4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.4-dev" - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Psr7\\": "src/" - }, - "files": [ - "src/functions_include.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - } - ], - "description": "PSR-7 message implementation", - "keywords": [ - "http", - "message", - "stream", - "uri" - ], - "time": "2016-06-24 23:00:38" - }, { "name": "psr/http-message", "version": "1.0.1", @@ -238,6 +180,64 @@ "promises" ], "time": "2016-12-22 14:09:01" + }, + { + "name": "ringcentral/psr7", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/ringcentral/psr7.git", + "reference": "2594fb47cdc659f3fcf0aa1559b7355460555303" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ringcentral/psr7/zipball/2594fb47cdc659f3fcf0aa1559b7355460555303", + "reference": "2594fb47cdc659f3fcf0aa1559b7355460555303", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "psr/http-message": "~1.0" + }, + "provide": { + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "RingCentral\\Psr7\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "PSR-7 message implementation", + "keywords": [ + "http", + "message", + "stream", + "uri" + ], + "time": "2016-03-25 17:36:49" } ], "packages-dev": [ diff --git a/src/CacheKey.php b/src/CacheKey.php new file mode 100644 index 0000000..0187a11 --- /dev/null +++ b/src/CacheKey.php @@ -0,0 +1,51 @@ +getScheme(), + (string)$uri->getHost(), + (string)$uri->getPort(), + self::chunkUp(md5((string)$uri->getPath()), $glue), + self::chunkUp(md5((string)$uri->getQuery()), $glue), + ] + ), + $glue + ); + } + + /** + * @param string $string + * @param string $glue + * @return string + */ + private static function chunkUp(string $string, string $glue): string + { + return implode($glue, str_split($string, 2)); + } + + /** + * @param string $string + * @return string + */ + private static function stripExtraSlashes(string $string, string $glue): string + { + return preg_replace('#' . $glue . '+#', $glue, $string); + } +} diff --git a/src/CacheMiddleware.php b/src/CacheMiddleware.php index df8cd07..c068ff4 100644 --- a/src/CacheMiddleware.php +++ b/src/CacheMiddleware.php @@ -4,18 +4,21 @@ use ApiClients\Foundation\Middleware\DefaultPriorityTrait; use ApiClients\Foundation\Middleware\MiddlewareInterface; -use GuzzleHttp\Psr7\Response; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\UriInterface; use React\Cache\CacheInterface; use React\Promise\CancellablePromiseInterface; +use React\Promise\PromiseInterface; +use RingCentral\Psr7\BufferStream; +use function React\Promise\reject; use function React\Promise\resolve; -class CacheMiddleware implements MiddlewareInterface +final class CacheMiddleware implements MiddlewareInterface { use DefaultPriorityTrait; + const DEFAULT_GLUE = '/'; + /** * @var CacheInterface */ @@ -27,12 +30,19 @@ class CacheMiddleware implements MiddlewareInterface private $key; /** - * @param CacheInterface$cache + * @var RequestInterface */ - public function __construct(CacheInterface $cache) - { - $this->cache = $cache; - } + private $request; + + /** + * @var bool + */ + private $store = false; + + /** + * @var StrategyInterface + */ + private $strategy; /** * @param RequestInterface $request @@ -41,34 +51,37 @@ public function __construct(CacheInterface $cache) */ public function pre(RequestInterface $request, array $options = []): CancellablePromiseInterface { - if ($request->getMethod() !== 'GET') { + if (!isset($options[self::class][Options::CACHE]) || !isset($options[self::class][Options::STRATEGY])) { + return resolve($request); + } + $this->cache = $options[self::class][Options::CACHE]; + $this->strategy = $options[self::class][Options::STRATEGY]; + if (!($this->cache instanceof CacheInterface) || !($this->strategy instanceof StrategyInterface)) { return resolve($request); } - $this->key = $this->determineCacheKey($request->getUri()); - return $this->cache->get($this->key)->then(function (string $document) { - return resolve( - $this->buildResponse($document) - ); - }, function () use ($request) { + if ($request->getMethod() !== 'GET') { return resolve($request); - }); - } + } - /** - * @param string $document - * @return Response - */ - protected function buildResponse(string $document): Response - { - $document = json_decode($document, true); - return new Response( - $document['status_code'], - $document['headers'], - $document['body'], - $document['protocol_version'], - $document['reason_phrase'] + $this->request = $request; + $this->key = CacheKey::create( + $this->request->getUri(), + $options[self::class][Options::GLUE] ?? self::DEFAULT_GLUE ); + + return $this->cache->get($this->key)->then(function (string $json) { + $document = Document::createFromString($json); + + if ($document->hasExpired()) { + $this->cache->remove($this->key); + return resolve($this->request); + } + + return reject($document->getResponse()); + }, function () { + return resolve($this->request); + }); } /** @@ -78,60 +91,43 @@ protected function buildResponse(string $document): Response */ public function post(ResponseInterface $response, array $options = []): CancellablePromiseInterface { - if (!is_string($this->key)) { + if (!($this->request instanceof RequestInterface)) { return resolve($response); } - $contents = (string)$response->getBody(); - - $document = [ - 'body' => $contents, - 'headers' => $response->getHeaders(), - 'protocol_version' => $response->getProtocolVersion(), - 'reason_phrase' => $response->getReasonPhrase(), - 'status_code' => $response->getStatusCode(), - ]; - - $this->cache->set($this->key, json_encode($document)); - - return resolve( - new Response( - $response->getStatusCode(), - $response->getHeaders(), - $contents, - $response->getProtocolVersion(), - $response->getReasonPhrase() - ) - ); - } + $this->store = $this->strategy->decide($this->request, $response); + if (!$this->store) { + return resolve($response); + } - /** - * @param UriInterface $uri - * @return string - */ - protected function determineCacheKey(UriInterface $uri): string - { - return $this->stripExtraSlashes( - implode( - '/', - [ - (string)$uri->getScheme(), - (string)$uri->getHost(), - (string)$uri->getPort(), - (string)$uri->getPath(), - md5((string)$uri->getQuery()), - ] - ) - ); + return $this->hasBody($response)->then(function (ResponseInterface $response) { + $document = Document::createFromResponse( + $response, + $this->strategy->determineTtl( + $this->request, + $response + ) + ); + + $this->cache->set($this->key, (string)$document); + + return resolve($document->getResponse()); + }, function () use ($response) { + return resolve($response); + }); } /** - * @param string $string - * @return string + * @param ResponseInterface $response + * @return PromiseInterface */ - protected function stripExtraSlashes(string $string): string + protected function hasBody(ResponseInterface $response): PromiseInterface { - return preg_replace('#/+#', '/', $string); + if ($response->getBody() instanceof BufferStream) { + return resolve($response); + } + + return reject(); } } diff --git a/src/Document.php b/src/Document.php new file mode 100644 index 0000000..ff67d39 --- /dev/null +++ b/src/Document.php @@ -0,0 +1,78 @@ +response = $response; + $this->expiresAt = $expiresAt; + } + + /** + * @return ResponseInterface + */ + public function getResponse(): ResponseInterface + { + return $this->response; + } + + /** + * @return bool + */ + public function hasExpired(): bool + { + return time() >= $this->expiresAt; + } + + public function __toString(): string + { + $contents = $this->response->getBody()->getContents(); + $stream = new BufferStream(strlen($contents)); + $stream->write($contents); + $this->response = $this->response->withBody($stream); + return json_encode([ + 'status_code' => $this->response->getStatusCode(), + 'headers' => $this->response->getHeaders(), + 'body' => $contents, + 'protocol_version' => $this->response->getProtocolVersion(), + 'reason_phrase' => $this->response->getReasonPhrase(), + 'expires_at' => $this->expiresAt, + ]); + } +} diff --git a/src/Options.php b/src/Options.php new file mode 100644 index 0000000..67dd2d3 --- /dev/null +++ b/src/Options.php @@ -0,0 +1,13 @@ + [ + Options::CACHE => $this->prophesize(CacheInterface::class)->reveal(), + Options::STRATEGY => $this->prophesize(StrategyInterface::class)->reveal(), + ], + ]; + $request = $this->prophesize(RequestInterface::class); $request->getMethod()->shouldBeCalled()->willReturn($method); $requestInstance = $request->reveal(); - $cache = $this->prophesize(CacheInterface::class); - $cache->set(Argument::type('string'), Argument::type('string'))->shouldNotBeCalled(); - $middleware = new CacheMiddleware($cache->reveal()); + $middleware = new CacheMiddleware(); - $this->assertSame( + self::assertSame( $requestInstance, await( - $middleware->pre($requestInstance), + $middleware->pre($requestInstance, $options), Factory::create() ) ); @@ -55,128 +64,115 @@ public function testNotGet(string $method) $middleware->post($this->prophesize(ResponseInterface::class)->reveal()); } - public function provideUri() + public function testPreGetCache() { - $uri = $this->prophesize(UriInterface::class); - $uri->getScheme()->shouldBeCalled()->willReturn('https'); - $uri->getHost()->shouldBeCalled()->willReturn('example.com'); - $uri->getPort()->shouldBeCalled()->willReturn(); - $uri->getPath()->shouldBeCalled()->willReturn('/'); - $uri->getQuery()->shouldBeCalled()->willReturn(''); - - yield [$uri->reveal()]; - - $uri = $this->prophesize(UriInterface::class); - $uri->getScheme()->shouldBeCalled()->willReturn('https'); - $uri->getHost()->shouldBeCalled()->willReturn('example.com'); - $uri->getPort()->shouldBeCalled()->willReturn(); - $uri->getPath()->shouldBeCalled()->willReturn(); - $uri->getQuery()->shouldBeCalled()->willReturn(); - - yield [$uri->reveal()]; - - $uri = $this->prophesize(UriInterface::class); - $uri->getScheme()->shouldBeCalled()->willReturn('https'); - $uri->getHost()->shouldBeCalled()->willReturn('example.com'); - $uri->getPort()->shouldBeCalled()->willReturn(80); - $uri->getPath()->shouldBeCalled()->willReturn('/'); - $uri->getQuery()->shouldBeCalled()->willReturn('?blaat'); - - yield [$uri->reveal()]; + $documentString = (string)Document::createFromResponse( + new Response(123, [], 'foo.bar'), + 5 + ); + $cache = $this->prophesize(CacheInterface::class); + $cache->get(Argument::type('string'))->shouldBecalled()->willReturn(resolve($documentString)); + + $options = [ + CacheMiddleware::class => [ + Options::CACHE => $cache->reveal(), + Options::STRATEGY => $this->prophesize(StrategyInterface::class)->reveal(), + ], + ]; + + $request = new Request('GET', 'foo.bar'); + + $response = null; + $middleware = new CacheMiddleware(); + $middleware->pre($request, $options)->otherwise(function ($responseObject) use (&$response) { + $response = $responseObject; + }); + self::assertNotNull($response); + + self::assertSame(123, $response->getStatusCode()); + self::assertSame('foo.bar', $response->getBody()->getContents()); } - /** - * @dataProvider provideUri - */ - public function testNotInCache(UriInterface $uri) + public function testPreGetNoCache() { - $request = $this->prophesize(RequestInterface::class); - $request->getUri()->shouldBeCalled()->willReturn($uri); - $request->getMethod()->shouldBeCalled()->willReturn('GET'); + $request = new Request('GET', 'foo.bar'); - $requestInstance = $request->reveal(); $cache = $this->prophesize(CacheInterface::class); - $cache->get(Argument::type('string'))->shouldBeCalled()->willReturn(new RejectedPromise()); - $middleware = new CacheMiddleware($cache->reveal()); + $cache->get(Argument::type('string'))->shouldBecalled()->willReturn(reject()); - $this->assertSame( - $requestInstance, - await( - $middleware->pre($requestInstance), - Factory::create() - ) - ); - } + $options = [ + CacheMiddleware::class => [ + Options::CACHE => $cache->reveal(), + Options::STRATEGY => $this->prophesize(StrategyInterface::class)->reveal(), + ], + ]; - public function testInCache() - { - $uri = $this->prophesize(UriInterface::class)->reveal(); + $middleware = new CacheMiddleware(); + $response = await($middleware->pre($request, $options), Factory::create()); - $request = $this->prophesize(RequestInterface::class); - $request->getUri()->shouldBeCalled()->willReturn($uri); - $request->getMethod()->shouldBeCalled()->willReturn('GET'); - $requestInstance = $request->reveal(); + self::assertSame($request, $response); + } - $document = '{"body":"foo","headers":[],"protocol_version":3.0,"reason_phrase":"w00t w00t","status_code":9001}'; - $cache = $this->prophesize(CacheInterface::class); - $cache->get(Argument::type('string'))->shouldBeCalled()->willReturn(new FulfilledPromise($document)); - $middleware = new CacheMiddleware($cache->reveal()); - - $response = await( - $middleware->pre($requestInstance)->then(null, function (ResponseInterface $response) { - return resolve($response); - }), - Factory::create() + public function testPreGetExpired() + { + $documentString = (string)Document::createFromResponse( + new Response(123, [], 'foo.bar'), + 0 ); - $this->assertSame('foo', (string)$response->getBody()); - $this->assertSame([], $response->getHeaders()); - $this->assertSame(3.0, $response->getProtocolVersion()); - $this->assertSame('w00t w00t', $response->getReasonPhrase()); - $this->assertSame(9001, $response->getStatusCode()); - } + sleep(2); - public function testSaveCache() - { + $cache = $this->prophesize(CacheInterface::class); + $cache->get(Argument::type('string'))->shouldBecalled()->willReturn(resolve($documentString)); + $cache->remove(Argument::type('string'))->shouldBecalled(); - $uri = $this->prophesize(UriInterface::class); - $uri->getScheme()->shouldBeCalled()->willReturn('https'); - $uri->getHost()->shouldBeCalled()->willReturn('example.com'); - $uri->getPort()->shouldBeCalled()->willReturn(); - $uri->getPath()->shouldBeCalled()->willReturn('/'); - $uri->getQuery()->shouldBeCalled()->willReturn(''); + $options = [ + CacheMiddleware::class => [ + Options::CACHE => $cache->reveal(), + Options::STRATEGY => $this->prophesize(StrategyInterface::class)->reveal(), + ], + ]; - $request = $this->prophesize(RequestInterface::class); - $request->getUri()->shouldBeCalled()->willReturn($uri->reveal()); - $request->getMethod()->shouldBeCalled()->willReturn('GET'); + $request = new Request('GET', 'foo.bar'); - $response = $this->prophesize(ResponseInterface::class); - $response->getBody()->shouldBeCalled()->willReturn('foo'); - $response->getHeaders()->shouldBeCalled()->willReturn([]); - $response->getProtocolVersion()->shouldBeCalled()->willReturn(3.0); - $response->getReasonPhrase()->shouldBeCalled()->willReturn('w00t w00t'); - $response->getStatusCode()->shouldBeCalled()->willReturn(9001); - $responseInstance = $response->reveal(); + $middleware = new CacheMiddleware(); + $response = await($middleware->pre($request, $options), Factory::create()); - $cache = $this->prophesize(CacheInterface::class); - $cache->get(Argument::type('string'))->shouldBeCalled()->willReturn(new RejectedPromise()); - $cache->set(Argument::type('string'), Argument::any('string'))->shouldBeCalled(); - $middleware = new CacheMiddleware($cache->reveal()); + self::assertSame($request, $response); + } - await( - $middleware->pre($request->reveal()), - Factory::create() - ); + public function testPost() + { + $request = new Request('GET', 'foo.bar'); - $processedResponse = await( - $middleware->post($responseInstance), - Factory::create() - ); + $body = 'foo.bar'; + $stream = new BufferStream(strlen($body)); + $stream->write($body); + $response = (new Response(200, []))->withBody($stream); - $this->assertSame('foo', (string)$processedResponse->getBody()); - $this->assertSame([], $processedResponse->getHeaders()); - $this->assertSame(3.0, $processedResponse->getProtocolVersion()); - $this->assertSame('w00t w00t', $processedResponse->getReasonPhrase()); - $this->assertSame(9001, $processedResponse->getStatusCode()); + $cache = $this->prophesize(CacheInterface::class); + $cache->get(Argument::type('string'))->shouldBecalled()->willReturn(reject()); + $cache->set( + Argument::type('string'), + Argument::type('string') + )->shouldBecalled(); + + $strategy = $this->prophesize(StrategyInterface::class); + $strategy->determineTtl(Argument::type(RequestInterface::class), Argument::type(ResponseInterface::class))->shouldBeCalled()->willReturn(true); + $strategy->decide(Argument::type(RequestInterface::class), Argument::type(ResponseInterface::class))->shouldBeCalled()->willReturn(true); + + $options = [ + CacheMiddleware::class => [ + Options::CACHE => $cache->reveal(), + Options::STRATEGY => $strategy->reveal(), + ], + ]; + + $middleware = new CacheMiddleware(); + $middleware->pre($request, $options); + $responseObject = await($middleware->post($response, $options), Factory::create()); + + self::assertSame($response->getStatusCode(), $responseObject->getStatusCode()); + self::assertSame($body, $responseObject->getBody()->getContents()); } } diff --git a/tests/DocumentTest.php b/tests/DocumentTest.php new file mode 100644 index 0000000..7c0dc92 --- /dev/null +++ b/tests/DocumentTest.php @@ -0,0 +1,102 @@ +getStatusCode(), $document->getResponse()->getStatusCode()); + self::assertSame($response->getHeaders(), $document->getResponse()->getHeaders()); + self::assertSame($response->getBody()->getContents(), $document->getResponse()->getBody()->getContents()); + self::assertSame($response->getProtocolVersion(), $document->getResponse()->getProtocolVersion()); + self::assertSame($response->getReasonPhrase(), $document->getResponse()->getReasonPhrase()); + self::assertSame($expired, $document->hasExpired()); + } + + public function testCreateFromResponse() + { + $response = new Response( + 200, + [], + 'foo.bar', + '3.0', + 'OK' + ); + + $document = Document::createFromResponse($response, 1); + self::assertFalse($document->hasExpired()); + self::assertSame($response, $document->getResponse()); + + sleep(2); + + self::assertTrue($document->hasExpired()); + } + + public function testCreateFromResponseNotExpired() + { + $response = new Response( + 200, + [], + 'foo.bar', + '3.0', + 'OK' + ); + + $document = Document::createFromResponse($response, 5); + self::assertFalse($document->hasExpired()); + self::assertSame($response, $document->getResponse()); + + sleep(2); + + self::assertFalse($document->hasExpired()); + } +} diff --git a/tests/Strategy/AlwaysTest.php b/tests/Strategy/AlwaysTest.php new file mode 100644 index 0000000..bc2a7db --- /dev/null +++ b/tests/Strategy/AlwaysTest.php @@ -0,0 +1,65 @@ +decide( + $this->prophesize(RequestInterface::class)->reveal(), + $this->prophesize(ResponseInterface::class)->reveal() + ) + ); + } + + public function provideTtl() + { + yield [ + Always::ALWAYS_TTL, + Always::ALWAYS_TTL, + ]; + + yield [ + Always::DEFAULT_TTL, + Always::DEFAULT_TTL, + ]; + + yield [ + 123, + 123, + ]; + } + + /** + * @dataProvider provideTtl + */ + public function testDetermineTtl(int $expectedTtl, int $ttl) + { + self::assertSame( + $expectedTtl, + (new Always())->determineTtl( + $this->prophesize(RequestInterface::class)->reveal(), + $this->prophesize(ResponseInterface::class)->reveal(), + $ttl + ) + ); + } + + public function testDetermineTtlDefault() + { + self::assertSame( + Always::ALWAYS_TTL, + (new Always())->determineTtl( + $this->prophesize(RequestInterface::class)->reveal(), + $this->prophesize(ResponseInterface::class)->reveal() + ) + ); + } +}