diff --git a/src/Abstract/Requests/Request.php b/src/Abstract/Requests/Request.php index 549150672..13e60cbce 100644 --- a/src/Abstract/Requests/Request.php +++ b/src/Abstract/Requests/Request.php @@ -32,19 +32,6 @@ abstract class Request extends LaravelRequest */ protected array $decode = []; - /** - * Defining the URL parameters (`/stores/{slug}/items`) allows applying - * validation rules on them and allows accessing them like request data. - * - * For example, you can use the `exists` validation rule on the `slug` parameter. - * And you can access the `slug` parameter using `$request->slug`. - * - * @example ['slug'] - * - * @var string[] - */ - protected array $urlParameters = []; - /** * To be used mainly from unit tests. */ @@ -73,7 +60,7 @@ public static function injectData(array $parameters = [], User|null $user = null * * @param array $properties * - * @return $this + * @return static */ public function withUrlParameters(array $properties): static { @@ -104,16 +91,6 @@ public function getDecodeArray(): array return $this->decode; } - /** - * Get the URL parameters array. - * - * @return string[] - */ - public function getUrlParametersArray(): array - { - return $this->urlParameters; - } - /** * check if a user has permission to perform an action. * User can set multiple permissions (separated with "|") and if the user has @@ -194,92 +171,16 @@ public function mapInput(array $fields): void public function all($keys = null): array { - $data = parent::all($keys); - - $data = $this->mergeUrlParameters($data); - - return $this->decodeHashedIds($data); - } - - /** - * apply validation rules to the ID's in the URL, since Laravel - * doesn't validate them by default! - * - * Now you can use validation rules like this: `'id' => 'required|integer|exists:items,id'` - */ - protected function mergeUrlParameters(array $requestData): array - { - foreach ($this->urlParameters as $param) { - $requestData[$param] = $this->route($param); - } - - return $requestData; - } - - /** - * without decoding the encoded id's you won't be able to use - * validation features like `exists:table,id`. - */ - protected function decodeHashedIds(array $data): array - { - if ([] !== $this->decode && config('apiato.hash-id')) { - foreach ($this->decode as $key) { - $data = $this->decodeRecursive($data, explode('.', $key), $key); - } - } - - return $data; - } - - private function decodeRecursive($data, $keys, string $currentField): mixed - { - if (is_null($data)) { - return $data; + if ([] === $this->decode || !config('apiato.hash-id')) { + return parent::all($keys); } - if (empty($keys)) { - if ($this->skipHashIdDecode($data)) { - return $data; - } - - if (!is_string($data)) { - throw new \RuntimeException('String expected, got ' . gettype($data)); - } - - $decodedField = hashids()->tryDecode($data); - - if (is_null($decodedField)) { - throw new \RuntimeException('ID (' . $currentField . ') is incorrect, consider using the hashed ID.'); - } + $routeParams = is_null($this->route()) ? [] : $this->route()->parameters(); - return $decodedField; - } - - // take the first element from the field - $field = array_shift($keys); - - if ('*' === $field) { - // process each field of the array (and go down one level!) - $fields = Arr::wrap($data); - foreach ($fields as $key => $value) { - $data[$key] = $this->decodeRecursive($value, $keys, $currentField . '[' . $key . ']'); - } - - return $data; - } - - if (!array_key_exists($field, $data)) { - return $data; - } - - $data[$field] = $this->decodeRecursive($data[$field], $keys, $field); - - return $data; - } - - public function skipHashIdDecode($field): bool - { - return empty($field); + return hashids()->decodeFields([ + ...parent::all($keys), + ...$routeParams, + ], $this->decode); } /** diff --git a/src/Generator/Stubs/requests/create.stub b/src/Generator/Stubs/requests/create.stub index 2d7bcd202..88533ba10 100644 --- a/src/Generator/Stubs/requests/create.stub +++ b/src/Generator/Stubs/requests/create.stub @@ -15,10 +15,6 @@ class {{class-name}} extends ParentRequest // 'id', ]; - protected array $urlParameters = [ - // 'id', - ]; - public function rules(): array { return [ diff --git a/src/Generator/Stubs/requests/delete.stub b/src/Generator/Stubs/requests/delete.stub index 4352f3fc7..00f1657ec 100644 --- a/src/Generator/Stubs/requests/delete.stub +++ b/src/Generator/Stubs/requests/delete.stub @@ -15,10 +15,6 @@ class {{class-name}} extends ParentRequest 'id', ]; - protected array $urlParameters = [ - 'id', - ]; - public function rules(): array { return [ diff --git a/src/Generator/Stubs/requests/edit.stub b/src/Generator/Stubs/requests/edit.stub index 4352f3fc7..00f1657ec 100644 --- a/src/Generator/Stubs/requests/edit.stub +++ b/src/Generator/Stubs/requests/edit.stub @@ -15,10 +15,6 @@ class {{class-name}} extends ParentRequest 'id', ]; - protected array $urlParameters = [ - 'id', - ]; - public function rules(): array { return [ diff --git a/src/Generator/Stubs/requests/find.stub b/src/Generator/Stubs/requests/find.stub index 4352f3fc7..00f1657ec 100644 --- a/src/Generator/Stubs/requests/find.stub +++ b/src/Generator/Stubs/requests/find.stub @@ -15,10 +15,6 @@ class {{class-name}} extends ParentRequest 'id', ]; - protected array $urlParameters = [ - 'id', - ]; - public function rules(): array { return [ diff --git a/src/Generator/Stubs/requests/generic.stub b/src/Generator/Stubs/requests/generic.stub index 2d7bcd202..88533ba10 100644 --- a/src/Generator/Stubs/requests/generic.stub +++ b/src/Generator/Stubs/requests/generic.stub @@ -15,10 +15,6 @@ class {{class-name}} extends ParentRequest // 'id', ]; - protected array $urlParameters = [ - // 'id', - ]; - public function rules(): array { return [ diff --git a/src/Generator/Stubs/requests/list.stub b/src/Generator/Stubs/requests/list.stub index 2d7bcd202..88533ba10 100644 --- a/src/Generator/Stubs/requests/list.stub +++ b/src/Generator/Stubs/requests/list.stub @@ -15,10 +15,6 @@ class {{class-name}} extends ParentRequest // 'id', ]; - protected array $urlParameters = [ - // 'id', - ]; - public function rules(): array { return [ diff --git a/src/Generator/Stubs/requests/store.stub b/src/Generator/Stubs/requests/store.stub index 2d7bcd202..88533ba10 100644 --- a/src/Generator/Stubs/requests/store.stub +++ b/src/Generator/Stubs/requests/store.stub @@ -15,10 +15,6 @@ class {{class-name}} extends ParentRequest // 'id', ]; - protected array $urlParameters = [ - // 'id', - ]; - public function rules(): array { return [ diff --git a/src/Generator/Stubs/requests/update.stub b/src/Generator/Stubs/requests/update.stub index 4352f3fc7..00f1657ec 100644 --- a/src/Generator/Stubs/requests/update.stub +++ b/src/Generator/Stubs/requests/update.stub @@ -15,10 +15,6 @@ class {{class-name}} extends ParentRequest 'id', ]; - protected array $urlParameters = [ - 'id', - ]; - public function rules(): array { return [ diff --git a/src/Support/HashidsManagerDecorator.php b/src/Support/HashidsManagerDecorator.php index 749eb7ae4..6e7cef0a0 100644 --- a/src/Support/HashidsManagerDecorator.php +++ b/src/Support/HashidsManagerDecorator.php @@ -2,6 +2,8 @@ namespace Apiato\Support; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; use Illuminate\Support\Traits\ForwardsCalls; use Illuminate\Support\Traits\Macroable; use Vinkla\Hashids\HashidsManager; @@ -87,6 +89,38 @@ public function decodeArray(array $hash): array return array_map(fn ($id) => $this->decode($id), $hash); } + /** + * without decoding the encoded id's you won't be able to use + * validation features like `exists:table,id`. + */ + public function decodeFields(array $source, array $keys): array + { + $flattened = Arr::dot($source); + + foreach ($keys as $pattern) { + $flattened = collect($flattened)->mapWithKeys(function ($value, $dotKey) use ($pattern) { + if (Str::is($pattern, $dotKey)) { + if (empty($value)) { + return [$dotKey => $value]; + } + + if (!is_string($value)) { + throw new \RuntimeException("String expected, got " . gettype($value)); + } + + $decoded = hashids()->tryDecode($value); + if (is_null($decoded)) { + throw new \RuntimeException("ID ({$dotKey}) is incorrect, consider using the hashed ID."); + } + return [$dotKey => $decoded]; + } + return [$dotKey => $value]; + })->all(); + } + + return Arr::undot($flattened); + } + /** * Dynamically pass method calls to the underlying resource. * diff --git a/tests/Functional/Abstract/RequestTest.php b/tests/Functional/Abstract/RequestTest.php new file mode 100644 index 000000000..4395478ef --- /dev/null +++ b/tests/Functional/Abstract/RequestTest.php @@ -0,0 +1,22 @@ + true]); + }); + + it('can decode specified ids', function (): void { + $bookId = hashids()->encode(5); + $result = $this->patchJson("v1/books/{$bookId}", [ + 'title' => 'New Title', + 'author_id' => hashids()->encode(10), + 'nested' => [ + 'id' => hashids()->encode(15), + ], + ]); + + $result->assertCreated(); + }); +})->covers(Request::class); diff --git a/tests/Unit/Abstract/Requests/RequestTest.php b/tests/Unit/Abstract/Requests/RequestTest.php index 088a2cf19..89e611054 100644 --- a/tests/Unit/Abstract/Requests/RequestTest.php +++ b/tests/Unit/Abstract/Requests/RequestTest.php @@ -3,153 +3,29 @@ namespace Tests\Unit\Foundation\Support\Traits; use Apiato\Abstract\Requests\Request; +use Pest\Expectation; describe(class_basename(Request::class), function (): void { - beforeEach(function (): void { - config(['apiato.hash-id' => true]); - }); - - function getSut(): Request - { - return new class extends Request { + it('skips decoding if disabled', function (bool $enabled): void { + config(['apiato.hash-id' => $enabled]); + $hashId = hashids()->tryEncode(123); + $data = ['id' => $hashId]; + $sut = (new class extends Request { public function setDecodeArray(array $decode): void { $this->decode = $decode; } - }; - } - - it('returns true for empty values', function (): void { - $result = getSut()->skipHashIdDecode(''); - - expect($result)->toBeTrue(); - }); - - it('returns false for non empty values', function (): void { - $result = getSut()->skipHashIdDecode('non-empty'); - - expect($result)->toBeFalse(); - }); - - it('skips decoding if disabled', function (): void { - config(['apiato.hash-id' => false]); - $data = ['id' => hashids()->tryEncode(123)]; - $sut = getSut()->merge($data); + })->merge($data); $sut->setDecodeArray(['id']); $result = $sut->all(); - expect($result)->toBe($data); - }); - - it('can decode hash ids', function (array $data, array $decode, array $expected): void { - $data = recursiveEncode($data); - $sut = getSut()->merge($data); - $sut->setDecodeArray($decode); - - $result = $sut->all(); - - expect($result)->toBe($expected); - })->with([ - 'top level value' => [ - ['id' => 1], - ['id'], - ['id' => 1], - ], - 'top level empty string' => [ - ['id' => ''], - ['id'], - ['id' => ''], - ], - 'nested value' => [ - ['data' => ['id' => 1]], - ['data.id'], - ['data' => ['id' => 1]], - ], - 'array' => [ - ['ids' => [1, 2]], - ['ids.*'], - ['ids' => [1, 2]], - ], - 'nested array' => [ - ['nested' => ['ids' => [1, 2]]], - ['nested.ids.*'], - ['nested' => ['ids' => [1, 2]]], - ], - 'string non existent key - should return value as is' => [ - ['non_existent_key' => 'value'], - ['id'], - ['non_existent_key' => 'value'], - ], - 'null top level value' => [ - ['id' => null], - ['id'], - ['id' => null], - ], - 'null nested value' => [ - ['data' => ['id' => null]], - ['data.id'], - ['data' => ['id' => null]], - ], - 'null array' => [ - ['ids' => [null, null]], - ['ids.*'], - ['ids' => [null, null]], - ], - 'null nested array' => [ - ['nested' => ['ids' => [null, null]]], - ['nested.ids.*'], - ['nested' => ['ids' => [null, null]]], - ], - ]); - - function recursiveEncode(array $data): array - { - return array_map(static function ($value) { - if (is_array($value)) { - return recursiveEncode($value); - } - if (is_int($value)) { - return hashids()->tryEncode($value); - } - - return $value; - }, $data); - } - - it('can decode nested associative arrays', function (): void { - $data = ['nested' => ['ids' => [['first' => 1, 'second' => hashids()->tryEncode(2)]]]]; - $sut = getSut()->merge($data); - $sut->setDecodeArray(['nested.ids.*.second']); - - $result = $sut->all(); - - expect($result)->toBe(['nested' => ['ids' => [['first' => 1, 'second' => 2]]]]); - }); - - it('throws in case of invalid hash id', function (array $data, array $decode): void { - $sut = getSut()->merge($data); - $sut->setDecodeArray($decode); - - expect(fn () => $sut->all()) - ->toThrow(\RuntimeException::class); + expect($result) + ->when($enabled, fn (Expectation $ex) => $ex->toBe(['id' => 123])) + ->when(!$enabled, fn (Expectation $ex) => $ex->toBe($data)); })->with([ - 'top level value' => [ - ['id' => 'invalid'], - ['id'], - ], - 'nested value' => [ - ['data' => ['id' => 'invalid']], - ['data.id'], - ], - 'array' => [ - ['ids' => ['invalid', 'invalid']], - ['ids.*'], - ], - 'nested array' => [ - ['nested' => ['ids' => ['invalid', 'invalid']]], - ['nested.ids.*'], - ], + [true], + [false], ]); it('has the sanitize method', function (): void { @@ -164,4 +40,4 @@ function recursiveEncode(array $data): array expect($result)->toBe(['age' => 100]); }); -})->only(); +})->covers(Request::class); diff --git a/tests/Unit/Foundation/Configuration/RoutingTest.php b/tests/Unit/Foundation/Configuration/RoutingTest.php index 21ed7d19f..7d1537f93 100644 --- a/tests/Unit/Foundation/Configuration/RoutingTest.php +++ b/tests/Unit/Foundation/Configuration/RoutingTest.php @@ -72,6 +72,13 @@ 'throttle:api', ], 'localhost', + ['PATCH'], + 'v1/books/{id}', + [ + 'api', + 'throttle:api', + ], + 'localhost', ['GET', 'HEAD'], 'v3/authors', [ diff --git a/tests/Unit/Support/HashidsManagerDecoratorTest.php b/tests/Unit/Support/HashidsManagerDecoratorTest.php index 1f9f39234..d210e3d9c 100644 --- a/tests/Unit/Support/HashidsManagerDecoratorTest.php +++ b/tests/Unit/Support/HashidsManagerDecoratorTest.php @@ -92,6 +92,112 @@ expect($result)->toBe([1, 2, 3]); }); + it('can decode hash ids', function (array $data, array $decode, array $expected): void { + $sut = new HashidsManagerDecorator(new HashidsManager(config(), app('hashids.factory'))); + + $result = $sut->decodeFields(recursiveEncode($data), $decode); + + expect($result)->toBe($expected); + })->with([ + 'top level value' => [ + ['id' => 1], + ['id'], + ['id' => 1], + ], + 'top level empty string' => [ + ['id' => ''], + ['id'], + ['id' => ''], + ], + 'nested value' => [ + ['data' => ['id' => 1]], + ['data.id'], + ['data' => ['id' => 1]], + ], + 'array' => [ + ['ids' => [1, 2]], + ['ids.*'], + ['ids' => [1, 2]], + ], + 'nested array' => [ + ['nested' => ['ids' => [1, 2]]], + ['nested.ids.*'], + ['nested' => ['ids' => [1, 2]]], + ], + 'string non existent key - should return value as is' => [ + ['non_existent_key' => 'value'], + ['id'], + ['non_existent_key' => 'value'], + ], + 'null top level value' => [ + ['id' => null], + ['id'], + ['id' => null], + ], + 'null nested value' => [ + ['data' => ['id' => null]], + ['data.id'], + ['data' => ['id' => null]], + ], + 'null array' => [ + ['ids' => [null, null]], + ['ids.*'], + ['ids' => [null, null]], + ], + 'null nested array' => [ + ['nested' => ['ids' => [null, null]]], + ['nested.ids.*'], + ['nested' => ['ids' => [null, null]]], + ], + ]); + + function recursiveEncode(array $data): array + { + return array_map(static function ($value) { + if (is_array($value)) { + return recursiveEncode($value); + } + if (is_int($value)) { + return hashids()->tryEncode($value); + } + + return $value; + }, $data); + } + + it('can decode nested associative arrays', function (): void { + $data = ['nested' => ['ids' => [['first' => 1, 'second' => hashids()->tryEncode(2)]]]]; + $sut = new HashidsManagerDecorator(new HashidsManager(config(), app('hashids.factory'))); + + $result = $sut->decodeFields($data, ['nested.ids.*.second']); + + expect($result)->toBe(['nested' => ['ids' => [['first' => 1, 'second' => 2]]]]); + }); + + it('throws in case of invalid hash id', function (array $data, array $decode): void { + $sut = new HashidsManagerDecorator(new HashidsManager(config(), app('hashids.factory'))); + + expect(fn () => $sut->decodeFields($data, $decode)) + ->toThrow(\RuntimeException::class); + })->with([ + 'top level value' => [ + ['id' => 'invalid'], + ['id'], + ], + 'nested value' => [ + ['data' => ['id' => 'invalid']], + ['data.id'], + ], + 'array' => [ + ['ids' => ['invalid', 'invalid']], + ['ids.*'], + ], + 'nested array' => [ + ['nested' => ['ids' => ['invalid', 'invalid']]], + ['nested.ids.*'], + ], + ]); + it('delegates method calls', function (): void { $sut = new HashidsManagerDecorator(new HashidsManager(config(), app('hashids.factory'))); diff --git a/workbench/app/Containers/MySection/Book/UI/API/Controllers/UpdateBookController.php b/workbench/app/Containers/MySection/Book/UI/API/Controllers/UpdateBookController.php new file mode 100644 index 000000000..836125f5c --- /dev/null +++ b/workbench/app/Containers/MySection/Book/UI/API/Controllers/UpdateBookController.php @@ -0,0 +1,19 @@ +created([ + 'all' => $request->all(), + 'id' => $request->id, + 'validated' => $request->validated(), + ]); + } +} diff --git a/workbench/app/Containers/MySection/Book/UI/API/Requests/CreateBookRequest.php b/workbench/app/Containers/MySection/Book/UI/API/Requests/CreateBookRequest.php index 51cb5c3c2..087a22893 100644 --- a/workbench/app/Containers/MySection/Book/UI/API/Requests/CreateBookRequest.php +++ b/workbench/app/Containers/MySection/Book/UI/API/Requests/CreateBookRequest.php @@ -15,10 +15,6 @@ class CreateBookRequest extends ParentRequest // 'id', ]; - protected array $urlParameters = [ - // 'id', - ]; - public function rules(): array { return [ diff --git a/workbench/app/Containers/MySection/Book/UI/API/Requests/UpdateBookRequest.php b/workbench/app/Containers/MySection/Book/UI/API/Requests/UpdateBookRequest.php new file mode 100644 index 000000000..1545de4b0 --- /dev/null +++ b/workbench/app/Containers/MySection/Book/UI/API/Requests/UpdateBookRequest.php @@ -0,0 +1,33 @@ + null, + 'roles' => null, + ]; + + protected array $decode = [ + 'id', + 'author_id', + 'nested.id', + ]; + + public function rules(): array + { + return [ + 'id' => 'required', + ]; + } + + public function authorize(): bool + { + return $this->check([ + 'hasAccess', + ]); + } +} diff --git a/workbench/app/Containers/MySection/Book/UI/API/Routes/UpdateBook.v1.private.php b/workbench/app/Containers/MySection/Book/UI/API/Routes/UpdateBook.v1.private.php new file mode 100644 index 000000000..f6ed237c5 --- /dev/null +++ b/workbench/app/Containers/MySection/Book/UI/API/Routes/UpdateBook.v1.private.php @@ -0,0 +1,7 @@ +