Skip to content
This repository has been archived by the owner on May 13, 2021. It is now read-only.

Commit

Permalink
Add support for If-Match and If-Unmodified-Since (issue #1)
Browse files Browse the repository at this point in the history
  • Loading branch information
micheh committed Apr 15, 2016
1 parent 2bfe909 commit f3215d5
Show file tree
Hide file tree
Showing 4 changed files with 442 additions and 41 deletions.
54 changes: 48 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![Code Coverage](https://scrutinizer-ci.com/g/micheh/psr7-cache/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/micheh/psr7-cache/?branch=master)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/micheh/psr7-cache/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/micheh/psr7-cache/?branch=master)

This library provides an easy way to either add cache relevant headers to a PSR-7 HTTP message implementation, or to extract cache information from a PSR-7 message (e.g. if a response is cacheable).
This library provides an easy way to either add cache relevant headers to a PSR-7 HTTP message implementation, or to extract cache and conditional request information from a PSR-7 message (e.g. if a response is cacheable).
It also provides a `Cache-Control` value object to provide an object oriented interface for the manipulation of the `Cache-Control` header.


Expand Down Expand Up @@ -33,21 +33,22 @@ During this time the client should use the response from the cache and should no

### Cache Validators
The application should also add Cache Validators to the response: An `ETag` header (and `Last-Modified` header if you know when the resource was last modified).
This way the client will also include the `ETag` and `Last-Modified` information in the request and the application can check if the client still has the current version.
This way the client will also include the `ETag` and `Last-Modified` information in the request and the application can check if the client still has the current state.

```php
/** @var \Psr\Http\Message\ResponseInterface $response */

$util = new \Micheh\Cache\CacheUtil();
$response = $util->withCache($response);
$response = $util->withETag($response, 'my-etag');
$response = $util->withLastModified($response, '2015-08-16 16:31:12');
$response = $util->withLastModified($response, new \DateTime());
```

### Revalidate a response
To determine if the client still has a current copy of the page and the response is not modified, you can use the `isNotModified` method.
Add the cache headers to the response and then call the method with both the request and the response.
If the response is not modified, return the empty response with the cache headers and a status code `304`.
Simply add the cache headers to the response and then call the method with both the request and the response.
The method will automatically compare the `If-None-Match` header of the request with the `ETag` header of the response (and/or the `If-Modified-Since` header of the request with the `Last-Modified` header of the response if available).
If the response is not modified, return the empty response with the cache headers and a status code `304` (Not Modified).
This will instruct the client to use the cached copy from the previous request, saving you CPU/memory usage and bandwidth.
Therefore it is important to keep the code before the `isNotModified` call as lightweight as possible to increase performance.
Don't create the complete response before this method.
Expand All @@ -59,7 +60,7 @@ Don't create the complete response before this method.
$util = new \Micheh\Cache\CacheUtil();
$response = $util->withCache($response);
$response = $util->withETag($response, 'my-etag');
$response = $util->withLastModified($response, '2015-08-16 16:31:12');
$response = $util->withLastModified($response, new \DateTime());

if ($util->isNotModified($request, $response)) {
return $response->withStatus(304);
Expand All @@ -69,6 +70,45 @@ if ($util->isNotModified($request, $response)) {
```


### Conditional request with unsafe method
While the procedure described above is usually optional and for safe methods (GET and HEAD), it is also possible to enforce that the client has the current resource state.
This is useful for unsafe methods (e.g. POST, PUT, PATCH or DELETE), because it can prevent lost updates (e.g. if another client updates the resource before your request).
It is a good idea to initially check if the request includes the appropriate headers (`If-Match` for an `ETag` and/or `If-Unmodified-Since` for a `Last-Modified` date) with the `hasStateValidator` method.
If the request does not include this information, abort the execution and return status code `428` (Precondition Required) or status code `403` (Forbidden) if you only want to use the original status codes.

```php
/** @var \Psr\Http\Message\RequestInterface $request */

$util = new \Micheh\Cache\CacheUtil();
if (!$util->hasStateValidator($request)) {
return $response->withStatus(428);
}
```

If the state validators are included in the request, you can check if the request has the current resource state and not an outdated version with the method `hasCurrentState`.
If the request has an outdated resource state (another `ETag` or an older `Last-Modified` date), abort the execution and return status code `412` (Precondition Failed).
Otherwise you can continue to process the request and update/delete the resource.
Once the resource is updated, it is a good idea to include the updated `ETag` (and `Last-Modified` date if available) in the response.

```php
/** @var \Psr\Http\Message\RequestInterface $request */
/** @var \Psr\Http\Message\ResponseInterface $response */

$util = new \Micheh\Cache\CacheUtil();
if (!$util->hasStateValidator($request)) {
return $response->withStatus(428);
}

$eTag = 'my-etag'
$lastModified = new \DateTime();
if (!$util->hasCurrentState($request, $eTag, $lastModified)) {
return $response->withStatus(412);
}

// process the request
```


## Available helper methods

Method | Description (see the phpDoc for more information)
Expand All @@ -80,6 +120,8 @@ Method | Description (see the phpDoc for more information)
`withETag` | Adds an `ETag` header
`withLastModified` | Adds a `Last-Modified` header from a timestamp, string or DateTime
`withCacheControl` | Adds a `Cache-Control` header from a string or value object
`hasStateValidator` | Checks if it is possible to determine the resource state
`hasCurrentState` | Checks if a request has the current resource state
`isNotModified` | Checks if a response is not modified
`isCacheable` | Checks if a response is cacheable by a public cache
`isFresh` | Checks if a response is fresh (age smaller than lifetime)
Expand Down
9 changes: 7 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "micheh/psr7-cache",
"description": "Cache helpers for PSR-7 HTTP Messages",
"description": "Cache and conditional request helpers for PSR-7 HTTP Messages",
"type": "library",
"license": "BSD-3-Clause",
"keywords": [
Expand All @@ -10,7 +10,12 @@
"psr-7",
"http-message",
"request",
"response"
"response",
"conditional",
"if-match",
"if-none-match",
"if-modified-since",
"if-unmodified-since"
],
"require": {
"php": ">=5.4",
Expand Down
140 changes: 136 additions & 4 deletions src/CacheUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,77 @@ public function withCacheControl(MessageInterface $message, CacheControl $cacheC
return $message->withHeader('Cache-Control', (string) $cacheControl);
}

/**
* Checks if a request has included validators (`ETag` and/or `Last-Modified` Date) which allow
* to determine if the client has the current resource state. The method will check if the
* `If-Match` and/or `If-Unmodified-Since` header is present.
*
* This method can be used for unsafe conditional requests (neither GET nor HEAD). If the
* request did not include a state validator (method returns `false`), abort the execution and
* return a `428` http status code (or `403` if you only want to use the original status
* codes). If the requests includes state validators (method returns `true`), you can continue
* and check if the client has the current state with the `hasCurrentState` method.
*
* @see hasCurrentState
* @link https://tools.ietf.org/html/rfc7232#section-6
*
* @param RequestInterface $request PSR-7 request to check
* @return bool True if the request includes state validators, false if it has no validators
*/
public function hasStateValidator(RequestInterface $request)
{
return $request->hasHeader('If-Match') || $request->hasHeader('If-Unmodified-Since');
}

/**
* Checks if the provided PSR-7 request has the current resource state. The method will check
* the `If-Match` and `If-Modified-Since` headers with the current ETag (and/or the Last-Modified
* date if provided). In addition, for a request which is not GET or HEAD, the method will check
* the `If-None-Match` header.
*
* Use this method to check conditional unsafe requests and to prevent lost updates. If the
* request does not have the current resource state (method returns `false`), abort and return
* status code `412`. In contrast, if the client has the current version of the resource (method
* returns `true`) you can safely continue the execution and update/delete the resource.
*
* @link https://tools.ietf.org/html/rfc7232#section-6
*
* @param RequestInterface $request PSR-7 request to check
* @param string $eTag Current ETag of the resource
* @param null|int|string|DateTime $lastModified Current Last-Modified date (optional)
* @return bool True if the request has the current resource state, false if the state is outdated
* @throws InvalidArgumentException If the Last-Modified date could not be parsed
*/
public function hasCurrentState(RequestInterface $request, $eTag, $lastModified = null)
{
if ($eTag) {
$eTag = '"' . trim($eTag, '"') . '"';
}

$ifMatch = $request->getHeaderLine('If-Match');
if ($ifMatch) {
if (!$this->matchesETag($eTag, $ifMatch)) {
return false;
}
} else {
$ifUnmodified = $request->getHeaderLine('If-Unmodified-Since');
if ($ifUnmodified && !$this->matchesModified($lastModified, $ifUnmodified)) {
return false;
}
}

if (in_array($request->getMethod(), ['GET', 'HEAD'], true)) {
return true;
}

$ifNoneMatch = $request->getHeaderLine('If-None-Match');
if ($ifNoneMatch && $this->matchesETag($eTag, $ifNoneMatch)) {
return false;
}

return true;
}

/**
* Method to check if the response is not modified and the request still has a valid cache, by
* comparing the `ETag` headers. If no `ETag` is available and the method is GET or HEAD, the
Expand All @@ -188,13 +259,13 @@ public function withCacheControl(MessageInterface $message, CacheControl $cacheC
* @param RequestInterface $request Request to check against
* @param ResponseInterface $response Response with ETag and/or Last-Modified header
* @return bool True if not modified, false if invalid cache
* @throws InvalidArgumentException If the current Last-Modified date could not be parsed
*/
public function isNotModified(RequestInterface $request, ResponseInterface $response)
{
$eTag = $response->getHeaderLine('ETag');
$noneMatch = $request->getHeaderLine('If-None-Match');
if ($eTag && $noneMatch) {
return $noneMatch === '*' || in_array($eTag, preg_split('/\s*,\s*/', $noneMatch), true);
if ($noneMatch) {
return $this->matchesETag($response->getHeaderLine('ETag'), $noneMatch);
}

if (!in_array($request->getMethod(), ['GET', 'HEAD'], true)) {
Expand All @@ -204,7 +275,7 @@ public function isNotModified(RequestInterface $request, ResponseInterface $resp
$lastModified = $response->getHeaderLine('Last-Modified');
$modifiedSince = $request->getHeaderLine('If-Modified-Since');

return ($lastModified && $modifiedSince && strtotime($modifiedSince) >= strtotime($lastModified));
return $this->matchesModified($lastModified, $modifiedSince);
}

/**
Expand Down Expand Up @@ -345,6 +416,31 @@ protected function getTimeFromValue($time)
throw new InvalidArgumentException('Could not create a valid date from ' . gettype($time) . '.');
}

/**
* Returns the Unix timestamp of the time parameter. The parameter can be an Unix timestamp,
* string or a DateTime object.
*
* @param int|string|DateTime $time
* @return int Unix timestamp
* @throws InvalidArgumentException If the time could not be parsed
*/
protected function getTimestampFromValue($time)
{
if (is_int($time)) {
return $time;
}

if ($time instanceof DateTime) {
return $time->getTimestamp();
}

if (is_string($time)) {
return strtotime($time);
}

throw new InvalidArgumentException('Could not create timestamp from ' . gettype($time) . '.');
}

/**
* Parses the Cache-Control header of a response and returns the Cache-Control object.
*
Expand All @@ -355,4 +451,40 @@ protected function getCacheControl(ResponseInterface $response)
{
return ResponseCacheControl::fromString($response->getHeaderLine('Cache-Control'));
}

/**
* Method to check if the current ETag matches the ETag of the request.
*
* @link https://tools.ietf.org/html/rfc7232#section-2.3.2
*
* @param string $currentETag The current ETag
* @param string $requestETags The ETags from the request
* @return bool True if the current ETag matches the ETags of the request, false otherwise
*/
private function matchesETag($currentETag, $requestETags)
{
if ($requestETags === '*') {
return (bool) $currentETag;
}

// TODO Add weak and strong comparison
return in_array($currentETag, preg_split('/\s*,\s*/', $requestETags), true);
}

/**
* Method to check if the current Last-Modified date matches the date of the request.
*
* @param int|string|DateTime $currentModified Current Last-Modified date
* @param string $requestModified Last-Modified date of the request
* @return bool True if the current date matches the date of the request, false otherwise
* @throws InvalidArgumentException If the current date could not be parsed
*/
private function matchesModified($currentModified, $requestModified)
{
if (!$currentModified) {
return false;
}

return $this->getTimestampFromValue($currentModified) <= strtotime($requestModified);
}
}
Loading

0 comments on commit f3215d5

Please sign in to comment.