diff --git a/database/migrations/2024_02_26_013401_add_idempotency_key_to_beam_claims_table.php b/database/migrations/2024_02_26_013401_add_idempotency_key_to_beam_claims_table.php new file mode 100644 index 0000000..09cfb9f --- /dev/null +++ b/database/migrations/2024_02_26_013401_add_idempotency_key_to_beam_claims_table.php @@ -0,0 +1,27 @@ +string('idempotency_key', 255)->nullable()->unique(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('beam_claims', function (Blueprint $table) { + $table->dropColumn('idempotency_key'); + }); + } +}; diff --git a/src/BeamServiceProvider.php b/src/BeamServiceProvider.php index 07f2d4c..39755bd 100644 --- a/src/BeamServiceProvider.php +++ b/src/BeamServiceProvider.php @@ -37,6 +37,7 @@ public function configurePackage(Package $package): void ->hasMigration('create_beam_scans_table') ->hasMigration('update_beams_table') ->hasMigration('add_collection_chain_id_to_beam_batches_table') + ->hasMigration('add_idempotency_key_to_beam_claims_table') ->hasRoute('enjin-platform-beam') ->hasTranslations(); } diff --git a/src/GraphQL/Mutations/ClaimBeamMutation.php b/src/GraphQL/Mutations/ClaimBeamMutation.php index fe172b0..91bfb5e 100644 --- a/src/GraphQL/Mutations/ClaimBeamMutation.php +++ b/src/GraphQL/Mutations/ClaimBeamMutation.php @@ -5,6 +5,7 @@ use Closure; use Enjin\Platform\Beam\GraphQL\Traits\HasBeamClaimConditions; use Enjin\Platform\Beam\GraphQL\Traits\HasBeamCommonFields; +use Enjin\Platform\Beam\Models\BeamClaim; use Enjin\Platform\Beam\Rules\CanClaim; use Enjin\Platform\Beam\Rules\NotExpired; use Enjin\Platform\Beam\Rules\NotOwner; @@ -13,10 +14,12 @@ use Enjin\Platform\Beam\Rules\VerifySignedMessage; use Enjin\Platform\Beam\Services\BeamService; use Enjin\Platform\Enums\Substrate\CryptoSignatureType; +use Enjin\Platform\GraphQL\Types\Input\Substrate\Traits\HasIdempotencyField; use Enjin\Platform\Interfaces\PlatformPublicGraphQlOperation; use Enjin\Platform\Rules\ValidSubstrateAccount; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Rebing\GraphQL\Support\Facades\GraphQL; @@ -24,6 +27,7 @@ class ClaimBeamMutation extends Mutation implements PlatformPublicGraphQlOperati { use HasBeamCommonFields; use HasBeamClaimConditions; + use HasIdempotencyField; /** * Get the mutation's attributes. @@ -67,6 +71,7 @@ public function args(): array 'description' => __('enjin-platform-beam::mutation.claim_beam.args.cryptoSignatureType'), 'defaultValue' => CryptoSignatureType::SR25519->name, ], + ...$this->getIdempotencyField(), ]; } @@ -81,9 +86,15 @@ public function resolve( Closure $getSelectFields, BeamService $beam ) { + $idempotencyKey = Arr::get($args, 'idempotencyKey'); + if ($idempotencyKey && BeamClaim::where('idempotency_key', $idempotencyKey)->exists()) { + return true; + } + return DB::transaction(fn () => $beam->claim( $args['code'], - $args['account'] + $args['account'], + $idempotencyKey, )); } diff --git a/src/GraphQL/Types/BeamClaimType.php b/src/GraphQL/Types/BeamClaimType.php index f392411..bd98938 100644 --- a/src/GraphQL/Types/BeamClaimType.php +++ b/src/GraphQL/Types/BeamClaimType.php @@ -105,6 +105,11 @@ public function fields(): array 'description' => __('enjin-platform::type.transaction.description'), 'is_relation' => true, ], + 'idempotencyKey' => [ + 'type' => GraphQL::type('String'), + 'description' => __('enjin-platform::type.transaction.field.idempotencyKey'), + 'alias' => 'idempotency_key', + ], ]; } } diff --git a/src/Jobs/ClaimBeam.php b/src/Jobs/ClaimBeam.php index 8b692b2..3d1e970 100644 --- a/src/Jobs/ClaimBeam.php +++ b/src/Jobs/ClaimBeam.php @@ -126,7 +126,13 @@ protected function buildBeamClaimAttributes(BatchService $batchService, Model $c protected function buildRequiredClaimAttributes(BatchService $batchService, Model $claim): array { return [ - ...Arr::only($this->data, ['wallet_public_key', 'claimed_at', 'state', 'ip_address']), + ...Arr::only($this->data, [ + 'wallet_public_key', + 'claimed_at', + 'state', + 'ip_address', + 'idempotency_key', + ]), 'beam_batch_id' => $batchService->getNextBatchId( BeamType::getEnumCase($claim->type), $claim->beam->collection_chain_id diff --git a/src/Models/Laravel/BeamClaim.php b/src/Models/Laravel/BeamClaim.php index ececf87..2751080 100644 --- a/src/Models/Laravel/BeamClaim.php +++ b/src/Models/Laravel/BeamClaim.php @@ -61,6 +61,7 @@ class BeamClaim extends BaseModel 'ip_address', 'code', 'nonce', + 'idempotency_key', ]; /** diff --git a/src/Services/BeamService.php b/src/Services/BeamService.php index 3b87f45..9879ca1 100644 --- a/src/Services/BeamService.php +++ b/src/Services/BeamService.php @@ -181,7 +181,7 @@ public function scanByCode(string $code, ?string $wallet = null): Model|null /** * Claim a beam. */ - public function claim(string $code, string $wallet): bool + public function claim(string $code, string $wallet, ?string $idempotencyKey = null): bool { $singleUseCode = null; $singleUse = static::isSingleUse($code); @@ -211,7 +211,7 @@ public function claim(string $code, string $wallet): bool throw new BeamException(__('enjin-platform-beam::error.no_more_claims')); } - ClaimBeam::dispatch($claim = $this->buildClaimBeamData($wallet, $beam, $singleUseCode)); + ClaimBeam::dispatch($claim = $this->buildClaimBeamData($wallet, $beam, $singleUseCode, $idempotencyKey)); event(new BeamClaimPending($claim)); Cache::decrement($key); Log::info("Claim beam: {$code}, Remaining: " . Cache::get($key), $claim); @@ -428,10 +428,14 @@ protected function createClaims(array $tokens, Model $beam): int /** * Build claim payload. */ - protected function buildClaimBeamData(string $wallet, Model $beam, ?string $singleUseCode = null): array - { + protected function buildClaimBeamData( + string $wallet, + Model $beam, + ?string $singleUseCode = null, + ?string $idempotencyKey = null + ): array { return array_merge( - $this->buildRequiredClaimBeamData($wallet, $beam, $singleUseCode), + $this->buildRequiredClaimBeamData($wallet, $beam, $singleUseCode, $idempotencyKey), ['extras' => $this->buildExtrasClaimBeamData($wallet, $beam)] ); } @@ -439,8 +443,12 @@ protected function buildClaimBeamData(string $wallet, Model $beam, ?string $sing /** * Build claim default data. */ - protected function buildRequiredClaimBeamData(string $wallet, Model $beam, ?string $singleUseCode = null): array - { + protected function buildRequiredClaimBeamData( + string $wallet, + Model $beam, + ?string $singleUseCode = null, + ?string $idempotencyKey = null + ): array { return [ 'wallet_public_key' => SS58Address::getPublicKey($wallet), 'claimed_at' => now(), @@ -449,7 +457,7 @@ protected function buildRequiredClaimBeamData(string $wallet, Model $beam, ?stri 'beam_id' => $beam->id, 'ip_address' => request()->getClientIp(), 'code' => $singleUseCode, - 'idempotency_key' => Str::uuid()->toString(), + 'idempotency_key' => $idempotencyKey ?: Str::uuid()->toString(), ]; } diff --git a/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php b/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php index 13c9903..1e4caa4 100644 --- a/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php +++ b/tests/Feature/GraphQL/Mutations/ClaimBeamTest.php @@ -11,11 +11,13 @@ use Enjin\Platform\Beam\Jobs\ClaimBeam; use Enjin\Platform\Beam\Models\BeamClaim; use Enjin\Platform\Beam\Rules\PassesClaimConditions; +use Enjin\Platform\Beam\Services\BatchService; use Enjin\Platform\Beam\Tests\Feature\GraphQL\TestCaseGraphQL; use Enjin\Platform\Beam\Tests\Feature\Traits\CreateBeamData; use Enjin\Platform\Beam\Tests\Feature\Traits\SeedBeamData; use Enjin\Platform\Enums\Substrate\CryptoSignatureType; use Enjin\Platform\Providers\Faker\SubstrateProvider; +use Enjin\Platform\Services\Database\WalletService; use Enjin\Platform\Support\Account; use Enjin\Platform\Support\SS58Address; use Illuminate\Support\Arr; @@ -104,6 +106,27 @@ public function test_it_can_claim_beam_with_ed25519(): void $this->genericClaimTest(CryptoSignatureType::ED25519); } + public function test_it_can_claim_beam_job_with_idempotency_key(): void + { + $data = [ + 'wallet_public_key' => $this->wallet->public_key, + 'claimed_at' => Carbon::now()->toDateTimeString(), + 'state' => 'PENDING', + 'ip_address' => fake()->ipv4(), + 'idempotency_key' => $uuid = fake()->uuid(), + 'beam' => $this->beam->toArray(), + 'beam_id' => $this->beam->id, + 'code' => '', + ]; + + (new ClaimBeam($data))->handle( + resolve(BatchService::class), + resolve(WalletService::class) + ); + + $this->assertTrue(BeamClaim::where('idempotency_key', $uuid)->exists()); + } + /** * Test it can remove a condition from the rule. */ diff --git a/tests/Feature/GraphQL/Resources/GetClaims.graphql b/tests/Feature/GraphQL/Resources/GetClaims.graphql index f27bc61..e2a6b45 100644 --- a/tests/Feature/GraphQL/Resources/GetClaims.graphql +++ b/tests/Feature/GraphQL/Resources/GetClaims.graphql @@ -22,6 +22,7 @@ query GetClaims( claimStatus quantity identifierCode + idempotencyKey attributes { key value diff --git a/tests/Feature/GraphQL/Resources/GetPendingClaims.graphql b/tests/Feature/GraphQL/Resources/GetPendingClaims.graphql index 5ed83db..8cfe172 100644 --- a/tests/Feature/GraphQL/Resources/GetPendingClaims.graphql +++ b/tests/Feature/GraphQL/Resources/GetPendingClaims.graphql @@ -18,6 +18,7 @@ query GetPendingClaims( claimStatus quantity identifierCode + idempotencyKey attributes { key value