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

Commit

Permalink
✨ Added Policy
Browse files Browse the repository at this point in the history
  • Loading branch information
bvtterfly committed Jun 23, 2022
1 parent 701ed56 commit 6438a02
Show file tree
Hide file tree
Showing 12 changed files with 216 additions and 87 deletions.
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Implementation inspired by [Stripe API](https://stripe.com/docs/api/idempotent_r
- Works only for `POST` requests. Other endpoints are ignored.
- Record and replay only successful(2xx) and server-side errors(5xx) responses, without touching your controller again.
- it's safe to retry, it doesn't record the response with client-side errors (4xx).
- To prevent accidental misuse of the cached responses, the request's hash-key is validated to ensure that the cached response is returned using the same combination of Idempotency-Key and Request.
- To prevent accidental misuse of the cached responses, the request's signature is validated to ensure that the cached response is returned using the same combination of Idempotency-Key and Request.
- Concurrency protection using Laravel's atomic locks to prevent race conditions.

## Installation
Expand All @@ -38,6 +38,8 @@ php artisan vendor:publish --tag="replay-config"
This is the contents of the published config file:

```php
use Bvtterfly\Replay\StripePolicy;

return [

/*
Expand Down Expand Up @@ -93,6 +95,18 @@ return [

'header_name' => 'Idempotency-Key',

/*
|--------------------------------------------------------------------------
| Policy
|--------------------------------------------------------------------------
|
| The policy determines whether a request is idempotent and whether the response should
| be recorded.
|
*/

'policy' => StripePolicy::class,

];
```

Expand All @@ -114,6 +128,27 @@ Route::post('/payments', function () {
})->middleware('replay');
```


### Custom Policy

Reply use Policy to determine whether a request is idempotent and whether the response should be recorded. By default, Reply includes and uses `StripePolicy` Policy.
To create your custom policy, you first need to implement the `\Butterfly\Replay\Contracts\Policy` contract:

```php
use Illuminate\Http\Request;
use Illuminate\Http\Response;

interface Policy
{
public function isIdempotentRequest(Request $request): bool;

public function isRecordableResponse(Response $response): bool;
}
```
If you want to view an example implementation take a look at the `StripePolicy` class.

For using this policy, We can change the `policy` in the config file.

## ✨ Client Usage

To perform an idempotent request, Client must provide an additional `Idempotency-Key : <key>` header with a unique key to the request.
Expand Down
14 changes: 14 additions & 0 deletions config/replay.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

use Bvtterfly\Replay\StripePolicy;

return [

/*
Expand Down Expand Up @@ -55,4 +57,16 @@

'header_name' => 'Idempotency-Key',

/*
|--------------------------------------------------------------------------
| Policy
|--------------------------------------------------------------------------
|
| The policy determines whether a request is idempotent and whether the response should
| be recorded.
|
*/

'policy' => StripePolicy::class,

];
15 changes: 15 additions & 0 deletions src/Contracts/Policy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Bvtterfly\Replay\Contracts;

use Illuminate\Http\Request;
use Illuminate\Http\Response;

interface Policy
{
public function isIdempotentRequest(Request $request): bool;

public function isRecordableResponse(Response $response): bool;
}
2 changes: 2 additions & 0 deletions src/Exceptions/InvalidConfiguration.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace Bvtterfly\Replay\Exceptions;

use Exception;
Expand Down
52 changes: 16 additions & 36 deletions src/Replay.php
Original file line number Diff line number Diff line change
@@ -1,32 +1,38 @@
<?php

declare(strict_types=1);

namespace Bvtterfly\Replay;

use Bvtterfly\Replay\Contracts\Policy;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\Response as StatusCode;

class Replay
{
private Policy $policy;

public function __construct()
{
$this->policy = app()->make(config('replay.policy'));
}

public function handle(Request $request, Closure $next): Response
{
if (! config('replay.enabled')) {
return $next($request);
}

if (! $request->isMethod('POST')) {
if (! $this->policy->isIdempotentRequest($request)) {
return $next($request);
}

if (! ($key = $this->getIdempotencyKey($request))) {
return $next($request);
}
$key = $this->getIdempotencyKey($request);

if ($recordedResponse = ReplayResponse::find($key)) {
return $recordedResponse->toResponse(
$this->hashRequestParams($request)
);
return $recordedResponse->toResponse(RequestHelper::signature($request));
}
$lock = Storage::lock($key);

Expand All @@ -36,8 +42,8 @@ public function handle(Request $request, Closure $next): Response

try {
$response = $next($request);
if ($this->isResponseRecordable($response)) {
ReplayResponse::save($key, $this->hashRequestParams($request), $response);
if ($this->policy->isRecordableResponse($response)) {
ReplayResponse::save($key, RequestHelper::signature($request), $response);
}

return $response;
Expand All @@ -46,34 +52,8 @@ public function handle(Request $request, Closure $next): Response
}
}

private function getIdempotencyKey(Request $request): string|null
private function getIdempotencyKey(Request $request): string
{
return $request->header(config('replay.header_name'));
}

protected function hashRequestParams(Request $request): string
{
$params = json_encode(
[
$request->ip(),
$request->path(),
$request->all(),
$request->headers->all(),
]
);

$hashAlgo = 'md5';

if (in_array('xxh3', hash_algos())) {
$hashAlgo = 'xxh3';
}

return hash($hashAlgo, $params);
}

protected function isResponseRecordable(Response $response): bool
{
return $response->isSuccessful()
|| $response->isServerError();
}
}
25 changes: 13 additions & 12 deletions src/ReplayResponse.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace Bvtterfly\Replay;

use Illuminate\Http\Response;
Expand All @@ -9,34 +11,33 @@ class ReplayResponse
{
public function __construct(
public string $key,
public string $requestHash,
public string $requestSignature,
public string $body,
public int $status,
public ?array $headers = []
) {
}

public static function fromResponse(string $key, string $requestHash, Response $response): ReplayResponse
public static function fromResponse(string $key, string $requestSignature, Response $response): ReplayResponse
{
return new self(
$key,
$requestHash,
$requestSignature,
(string) $response->getContent(),
$response->getStatusCode(),
$response->headers->all()
);
}

public function toResponse(string $requestHash): Response
public function toResponse(string $requestSignature): Response
{
if ($requestHash !== $this->requestHash) {
abort(
StatusCode::HTTP_CONFLICT,
'There was a mismatch between this request\'s parameters and the ' .
'parameters of a previously stored request with the same ' .
'Idempotency-Key.'
);
}
abort_if(
$requestSignature !== $this->requestSignature,
StatusCode::HTTP_CONFLICT,
'There was a mismatch between this request\'s parameters and the ' .
'parameters of a previously stored request with the same ' .
'Idempotency-Key.'
);

return response($this->body, $this->status, $this->headers);
}
Expand Down
2 changes: 2 additions & 0 deletions src/ReplayServiceProvider.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace Bvtterfly\Replay;

use Spatie\LaravelPackageTools\Package;
Expand Down
26 changes: 26 additions & 0 deletions src/RequestHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Bvtterfly\Replay;

use Illuminate\Http\Request;

class RequestHelper
{
public static function signature(Request $request): string
{
$hashAlgo = 'md5';

if (in_array('xxh3', hash_algos())) {
$hashAlgo = 'xxh3';
}

return hash($hashAlgo, json_encode(
[
$request->ip(),
$request->path(),
$request->all(),
$request->headers->all(),
]
));
}
}
2 changes: 2 additions & 0 deletions src/Storage.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace Bvtterfly\Replay;

use Bvtterfly\Replay\Exceptions\InvalidConfiguration;
Expand Down
23 changes: 23 additions & 0 deletions src/StripePolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Bvtterfly\Replay;

use Illuminate\Http\Request;
use Illuminate\Http\Response;

class StripePolicy implements Contracts\Policy
{
public function isIdempotentRequest(Request $request): bool
{
return $request->isMethod('POST')
&& $request->hasHeader(config('replay.header_name'));
}

public function isRecordableResponse(Response $response): bool
{
return $response->isSuccessful()
|| $response->isServerError();
}
}
Loading

0 comments on commit 6438a02

Please sign in to comment.