From 8f11ddee3ab3cf0729f44a69014bc5150707f246 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Sun, 3 Apr 2022 19:53:45 +0300 Subject: [PATCH 01/42] fix: support ukraine --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index e01c6d7f6..d51080d56 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,12 @@ The first fully customizable Laravel [JSON:API](https://jsonapi.org) builder. "CRUD" and protect your resources with 0 (zero) extra line of code. +
+ +Support Ukraine + +
+ ## Installation You can install the package via composer: From 253c9c903f1c4b2c5124fa407c6c22d045d4d324 Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Mon, 4 Jul 2022 18:01:16 +0300 Subject: [PATCH 02/42] Clean up & upgrading php + laravel (#464) * fix: cleaning the repository method * Fix styling * fix: wip * fix: wip * fix: wip * fix: wip * fix: wip * fix: wip * feat: drop laravel 8 support * fix: wip * Fix styling * fix: wip * Fix styling * fix: refactoring matches * fix: wip * Fix styling * fix: wip * fix: wip * Larastan (#461) * fix: support ukraine * adding larastan * wip * Fix styling * Fix styling * fix: wip * Fix styling * fix: wip * fix: php 8.1 * fix: cover windows tests Co-authored-by: binaryk * fix: delete unused Co-authored-by: binaryk --- .github/workflows/psalm.yml | 2 +- .github/workflows/tests.yml | 10 +- .gitignore | 2 +- ROADMAP.md | 12 ++ UPGRADING.md | 9 +- composer.json | 17 +- phpstan-baseline.neon | 0 phpstan.neon.dist | 14 ++ routes/api.php | 2 +- src/Actions/Action.php | 2 +- src/Bootstrap/RoutesBoot.php | 37 +--- src/Exceptions/RepositoryException.php | 27 +++ .../RepositoryNotFoundException.php | 6 +- src/Filters/Filter.php | 2 +- src/Filters/FilterDefinition.php | 2 +- src/Filters/MatchFilter.php | 10 +- src/Generators/DatabaseGenerator.php | 2 +- .../Controllers/ProfileUpdateController.php | 2 +- .../Concerns/InteractWithRepositories.php | 61 +++--- src/Repositories/Concerns/Testing.php | 9 +- src/Repositories/Repository.php | 10 +- src/Repositories/RepositoryEvents.php | 16 +- src/Repositories/UserProfile.php | 4 +- src/Restify.php | 24 ++- src/Services/Search/GlobalSearch.php | 13 +- src/Traits/AuthorizableModels.php | 22 +- src/Traits/InteractWithSearch.php | 8 +- src/helpers.php | 3 +- tests/Actions/FieldActionTest.php | 10 +- tests/Actions/ListActionsControllerTest.php | 2 +- tests/Actions/PerformActionControllerTest.php | 2 +- .../Index/RepositoryIndexControllerTest.php | 94 ++++----- tests/Controllers/ProfileControllerTest.php | 28 +-- .../RepositoryAttachControllerTest.php | 26 +-- .../RepositoryDestroyControllerTest.php | 2 +- .../RepositoryDetachControllerTest.php | 6 +- .../Controllers/RepositoryMiddlewaresTest.php | 4 +- .../RepositoryPatchControllerTest.php | 2 +- .../RepositoryShowControllerTest.php | 16 +- .../RepositoryStoreBulkControllerTest.php | 4 +- .../RepositoryStoreControllerTest.php | 6 +- .../RepositoryUpdateBulkControllerTest.php | 2 +- .../RepositoryUpdateControllerTest.php | 4 +- tests/Factories/UserFactory.php | 5 +- tests/Feature/Filters/AdvancedFilterTest.php | 6 +- tests/Feature/Filters/MatchFilterTest.php | 189 ++++++++++++++++++ tests/Feature/Filters/SortableFilterTest.php | 27 +++ tests/Feature/RepositorySearchServiceTest.php | 189 ------------------ tests/Fields/BelongsToFieldTest.php | 22 +- tests/Fields/BelongsToManyFieldTest.php | 4 +- tests/Fields/FileTest.php | 14 +- tests/Fields/HasManyTest.php | 20 +- tests/Fields/HasOneFieldTest.php | 86 ++++---- tests/Fields/ImageTest.php | 4 +- tests/Fields/MorphOneFieldTest.php | 2 +- tests/Fields/MorphToManyFieldTest.php | 24 +-- tests/Fixtures/Company/CompanyRepository.php | 2 +- tests/Fixtures/Post/PostPolicy.php | 25 +-- tests/Fixtures/User/DisableProfileAction.php | 4 +- tests/Fixtures/User/MockUser.php | 8 + tests/Fixtures/User/User.php | 27 +-- tests/Fixtures/User/UserRepository.php | 13 +- tests/Getters/ListGettersControllerTest.php | 4 +- tests/IntegrationTest.php | 8 +- .../2017_10_10_000000_create_users_table.php | 1 + .../RepositoryCustomPrefixTest.php | 4 +- tests/Repositories/RepositoryEventsTest.php | 9 +- tests/Unit/MatchableFilterTest.php | 30 --- tests/Unit/RepositoryWithRoutesTest.php | 2 +- 69 files changed, 655 insertions(+), 610 deletions(-) create mode 100644 ROADMAP.md create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon.dist create mode 100644 src/Exceptions/RepositoryException.php create mode 100644 tests/Fixtures/User/MockUser.php delete mode 100644 tests/Unit/MatchableFilterTest.php diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index 93c23a3ac..01eb00eb7 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -16,7 +16,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.0' + php-version: '8.1' extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick coverage: none diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7cf5df09d..b593215d2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,12 +9,12 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest, windows-latest] - php: [8.1, 8.0] - laravel: [^8.0, ^9.0] - stability: [prefer-stable] + php: [8.1] + laravel: [9.*] + stability: [prefer-lowest, prefer-stable] include: - - laravel: 8.* - testbench: ^6.6 + - laravel: 9.* + testbench: 7.* name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index e779a63f1..3c0a8da19 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,4 @@ docs/node_modules docs/.vuepress/dist .phpunit.result.cache .php-cs-fixer.cache - +phpstan.neon diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..04fb1291a --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,12 @@ +## Roadmap + +- Improve controllers +- Reduce the main Repository class by using traits +- Request validations should be rewritten +- Revisit the InteractWithRepositories trait and clean model queries accordingly +- Adding support for PHPStan and configure the level 4 +- Make sure any action is permitted unless the Model Policy exists +- Add PestPHP support +- Clean up all tests using AssertableJson +- Adding support for custom ActionLogs (ie ActionLog::register("project marked active by user Auth::id()", $project->id)) +- Adding a command that lists all Restify registered routes `php artisan restify:routes` diff --git a/UPGRADING.md b/UPGRADING.md index 0eab387fd..ed0b2ecee 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,6 +1,13 @@ # Upgrading -Because there are many breaking changes an upgrade is not that easy. There are many edge cases this guide does not cover. We accept PRs to improve this guide. +## From 6.x to 7.x + +- PHP8.1 is required +- Laravel 9 is required +- Restify.php - `repositoryForKey` renamed to `repositoryClassForKey` +- Repository.php: + - static `to` method renamed to `route` + - `related` static method deleted, replace with `include` ## From 6.2.1 to 6.3.0 diff --git a/composer.json b/composer.json index ac33e5a56..796be8ecf 100644 --- a/composer.json +++ b/composer.json @@ -18,17 +18,19 @@ } ], "require": { - "php": "^8.0", - "ext-json": "*", - "doctrine/dbal": "^2.10|^3.0", - "illuminate/contracts": "^8.37|^9.0", + "php": "^8.1", + "illuminate/contracts": "^9.0", "spatie/data-transfer-object": "^3.1", - "spatie/once": "^2.0|^3.0" + "spatie/once": "^3.0" }, "require-dev": { "brianium/paratest": "^6.2", - "nunomaduro/collision": "^5.3", - "orchestra/testbench": "^6.0|^7.0", + "doctrine/dbal": "^3.0", + "nunomaduro/collision": "^6.0", + "orchestra/testbench": "^7.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", "phpunit/phpunit": "^9.3", "spatie/laravel-ray": "^1.9", "vimeo/psalm": "^4.4" @@ -49,6 +51,7 @@ }, "scripts": { "psalm": "./vendor/bin/psalm --no-cache", + "analyse": "vendor/bin/phpstan analyse", "test": "./vendor/bin/testbench package:test --parallel --no-coverage", "test-coverage": "./vendor/bin/phpunit --coverage-html coverage" }, diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 000000000..e69de29bb diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 000000000..e4dc4a6e6 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,14 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 1 + paths: + - src + - config + - database + tmpDir: build/phpstan + checkMissingIterableValueType: false + ignoreErrors: + - '#Unsafe usage of new static#' + diff --git a/routes/api.php b/routes/api.php index 7c37665b0..23e9af909 100644 --- a/routes/api.php +++ b/routes/api.php @@ -32,7 +32,7 @@ Route::get('/{repository}/{repositoryId}/getters/{getter}', \Binaryk\LaravelRestify\Http\Controllers\PerformRepositoryGetterController::class)->name('restify.getters.repository.perform'); // API CRUD -Route::get('/{repository}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryIndexController::class)->name('restify.index'); +Route::get('/{repository}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryIndexController::class)->name('index'); Route::post('/{repository}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreController::class)->name('restify.store'); Route::post('/{repository}/bulk', \Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreBulkController::class)->name('restify.store.bulk'); Route::post('/{repository}/bulk/update', \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateBulkController::class)->name('restify.update.bulk'); diff --git a/src/Actions/Action.php b/src/Actions/Action.php index 5ed0f92d0..6df34623e 100644 --- a/src/Actions/Action.php +++ b/src/Actions/Action.php @@ -23,7 +23,7 @@ /** * Class Action - * @method JsonResponse handle(Request $request, Model|Collection $models, ?int $row) + * @method JsonResponse handle(Request $request, ?Model|Collection $models = null, ?int $row = null) * @package Binaryk\LaravelRestify\Actions */ abstract class Action implements JsonSerializable diff --git a/src/Bootstrap/RoutesBoot.php b/src/Bootstrap/RoutesBoot.php index b97027cd9..ac31a4387 100644 --- a/src/Bootstrap/RoutesBoot.php +++ b/src/Bootstrap/RoutesBoot.php @@ -2,22 +2,12 @@ namespace Binaryk\LaravelRestify\Bootstrap; -use Binaryk\LaravelRestify\Getters\Getter; -use Binaryk\LaravelRestify\Http\Controllers\PerformGetterController; use Binaryk\LaravelRestify\Http\Controllers\RepositoryIndexController; -use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Restify; -use Illuminate\Contracts\Foundation\CachesRoutes; -use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Route; class RoutesBoot { - public function __construct( - private Application $app, - ) { - } - public function boot(): void { $config = [ @@ -28,7 +18,6 @@ public function boot(): void ]; $this -// ->registerCustomGettersPerforms($config) ->defaultRoutes($config) ->registerPrefixed($config) ->registerIndexPrefixed($config); @@ -73,31 +62,7 @@ public function registerIndexPrefixed($config): self private function loadRoutesFrom(string $path): self { - if (! ($this->app instanceof CachesRoutes && $this->app->routesAreCached())) { - require $path; - } - - return $this; - } - - // @deprecated - public function registerCustomGettersPerforms($config): self - { - collect(Restify::$repositories) - ->filter(function ($repository) use ($config) { - return collect(app($repository) - ->getters(app(RestifyRequest::class))) - ->each(function (Getter $getter) use ($config, $repository) { - if (count($excludedMiddleware = $getter->excludedMiddleware())) { - Route::group($config, function () use ($excludedMiddleware, $repository, $getter) { - $getterKey = $getter->uriKey(); - - Route::get("/{repository}/getters/$getterKey", PerformGetterController::class) - ->withoutMiddleware($excludedMiddleware); - }); - } - }); - }); + require $path; return $this; } diff --git a/src/Exceptions/RepositoryException.php b/src/Exceptions/RepositoryException.php new file mode 100644 index 000000000..e2b7150fb --- /dev/null +++ b/src/Exceptions/RepositoryException.php @@ -0,0 +1,27 @@ + $class, + ]), code: 403); + } + + public static function routeUnauthorized(string $uri = null): self + { + return new self(__('Unauthorized to use the route :name. Check prefix.', [ + 'name' => $uri, + ]), code: 403); + } +} diff --git a/src/Exceptions/RepositoryNotFoundException.php b/src/Exceptions/RepositoryNotFoundException.php index 49e9149e1..215856702 100644 --- a/src/Exceptions/RepositoryNotFoundException.php +++ b/src/Exceptions/RepositoryNotFoundException.php @@ -6,8 +6,10 @@ class RepositoryNotFoundException extends RuntimeException { - public static function make(string $message): self + public static function make(string $class): self { - return new static($message, 404); + return new static(__('Repository :name not found.', [ + 'name' => $class, + ])); } } diff --git a/src/Filters/Filter.php b/src/Filters/Filter.php index f7ec47085..4135d553d 100644 --- a/src/Filters/Filter.php +++ b/src/Filters/Filter.php @@ -207,7 +207,7 @@ public function setAdvanced(bool $advanced = true): self public function getRelatedRepository(): ?array { return ($key = $this->getRelatedRepositoryKey()) - ? with(Restify::repositoryForKey($key), function ($repository = null) { + ? with(Restify::repositoryClassForKey($key), function ($repository = null) { if (is_subclass_of($repository, Repository::class)) { return [ 'key' => $repository::uriKey(), diff --git a/src/Filters/FilterDefinition.php b/src/Filters/FilterDefinition.php index 34b30fd3b..dfeb07da2 100644 --- a/src/Filters/FilterDefinition.php +++ b/src/Filters/FilterDefinition.php @@ -26,7 +26,7 @@ public function getRelatedRepositoryKey(): ?string public function getRelatedRepositoryUrl(): ?string { return ($key = $this->getRelatedRepositoryKey()) - ? with(Restify::repositoryForKey($key), function ($repository = null) { + ? with(Restify::repositoryClassForKey($key), function ($repository = null) { if (is_subclass_of($repository, Repository::class)) { return Restify::path($repository::uriKey()); } diff --git a/src/Filters/MatchFilter.php b/src/Filters/MatchFilter.php index 02ae8bd3e..4e426680b 100644 --- a/src/Filters/MatchFilter.php +++ b/src/Filters/MatchFilter.php @@ -67,7 +67,15 @@ public function filter(RestifyRequest $request, Builder | Relation $query, $valu break; case RestifySearchable::MATCH_DATETIME: - $query->whereDate($field, $this->negation ? '!=' : '=', $value); + if (count($values = explode(',', $value)) > 1) { + if ($this->negation) { + $query->whereNotBetween($field, $values); + } else { + $query->whereBetween($field, $values); + } + } else { + $query->whereDate($field, $this->negation ? '!=' : '=', $value); + } break; case RestifySearchable::MATCH_BETWEEN: diff --git a/src/Generators/DatabaseGenerator.php b/src/Generators/DatabaseGenerator.php index 778671aee..ad9c50b01 100644 --- a/src/Generators/DatabaseGenerator.php +++ b/src/Generators/DatabaseGenerator.php @@ -75,7 +75,7 @@ public function integer(Column $columnDefinition, $column): ?int $guessTable = Str::pluralStudly(Str::beforeLast($column, '_id')); if (Schema::hasTable($guessTable)) { - return optional(DB::table($guessTable)->inRandomOrder()->first())->id ?? $this->faker->randomNumber(4); + return optional(DB::table($guessTable)->inRandomOrder()->first())->getKey() ?? $this->faker->randomNumber(4); } } diff --git a/src/Http/Controllers/ProfileUpdateController.php b/src/Http/Controllers/ProfileUpdateController.php index 545179ff6..f3a757c66 100644 --- a/src/Http/Controllers/ProfileUpdateController.php +++ b/src/Http/Controllers/ProfileUpdateController.php @@ -22,7 +22,7 @@ public function __invoke(RepositoryShowRequest $request): JsonResponse } $request->validate([ - 'email' => 'sometimes|required|unique:users,email,'.$user->id, + 'email' => 'sometimes|required|unique:users,email,'.$user->getKey(), 'password' => 'sometimes|required|min:5|confirmed', ]); diff --git a/src/Http/Requests/Concerns/InteractWithRepositories.php b/src/Http/Requests/Concerns/InteractWithRepositories.php index 086ea838c..c474e7316 100644 --- a/src/Http/Requests/Concerns/InteractWithRepositories.php +++ b/src/Http/Requests/Concerns/InteractWithRepositories.php @@ -2,70 +2,69 @@ namespace Binaryk\LaravelRestify\Http\Requests\Concerns; -use Binaryk\LaravelRestify\Exceptions\RepositoryNotFoundException; +use Binaryk\LaravelRestify\Exceptions\RepositoryException; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Pipeline\Pipeline; +use Throwable; /** * @mixin RestifyRequest */ trait InteractWithRepositories { + /** + * @throws Throwable + */ public function repository($key = null): Repository { - $repository = tap(Restify::repositoryForKey($key ?? $this->route('repository')), function (string $repository) { - /** * @var Repository $repository */ - if (is_null($repository)) { - throw RepositoryNotFoundException::make(__('Repository :name not found.', [ - 'name' => $repository, - ])); - } - - if (! $repository::authorizedToUseRepository($this)) { - abort(403, __( - 'Unauthorized to view repository :name. Check "allowRestify" policy.', - [ - 'name' => $repository, - ] - )); - } - - if (! $repository::authorizedToUseRoute($this)) { - abort(403, __('Unauthorized to use the route :name. Check prefix.', [ - 'name' => $this->getRequestUri(), - ])); - } + try { + $key = $key ?? $this->route('repository'); + + throw_if(is_null($key), RepositoryException::missingKey()); + + $repository = Restify::repository($key); + + throw_unless( + $repository::authorizedToUseRepository($this), + RepositoryException::unauthorized($repository::uriKey()) + ); + + throw_unless( + $repository::authorizedToUseRoute($this), + RepositoryException::routeUnauthorized($this->getRequestUri()) + ); app(Pipeline::class) ->send($this) ->through(optional($repository::collectMiddlewares($this))->all()) ->thenReturn(); - }); - return $repository::isMock() - ? $repository::getMock() - : $repository::resolveWith($repository::newModel()); + return $repository; + } catch (RepositoryException $e) { + abort($e->getCode(), $e->getMessage()); + } } - public function repositoryWith($model, $uriKey = null): Repository + public function repositoryWith(Model $model, string $uriKey = null): Repository { $repository = $this->repository($uriKey); return $repository::resolveWith($model); } - public function model($uriKey = null): Model + public function model(string $uriKey = null): Model { $repository = $this->repository($uriKey); return $repository::newModel(); } - public function newQuery($uriKey = null): Builder | Relation + public function newQuery(string $uriKey = null): Builder|Relation { if (! $this->isViaRepository()) { return $this->model($uriKey)->newQuery(); @@ -79,7 +78,7 @@ public function viaQuery(): Relation return $this->relatedEagerField()->getRelation(); } - public function modelQuery(string $repositoryId = null, string $uriKey = null): Builder | Relation + public function modelQuery(string $repositoryId = null, string $uriKey = null): Builder|Relation { return $this->newQuery($uriKey)->whereKey( $repositoryId ?? $this->route('repositoryId') diff --git a/src/Repositories/Concerns/Testing.php b/src/Repositories/Concerns/Testing.php index 71da04f65..73988586b 100644 --- a/src/Repositories/Concerns/Testing.php +++ b/src/Repositories/Concerns/Testing.php @@ -5,6 +5,7 @@ use Binaryk\LaravelRestify\Actions\Action; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; +use Illuminate\Support\Str; /** * Trait Testing @@ -15,9 +16,9 @@ */ trait Testing { - public static function to(string $path = null, array $query = []): string + public static function route(string $path = null, array $query = []): string { - $base = Restify::path().'/'.static::uriKey(); + $base = Str::replaceFirst('//', '/', Restify::path().'/'.static::uriKey()); $route = $path ? $base.'/'.$path @@ -33,7 +34,7 @@ public static function action(string $action, string|int $key = null): string { $path = $key ? "$key/actions" : 'actions'; - return static::to($path, [ + return static::route($path, [ 'action' => app($action)->uriKey(), ]); } @@ -42,7 +43,7 @@ public static function getter(string $getter, string|int $key = null): string { $path = $key ? "$key/getters" : 'getters'; - return static::to($path . '/' .app($getter)->uriKey()); + return static::route($path.'/'.app($getter)->uriKey()); } public function dd(string $prop = null): void diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index fe55afdf7..991a774f8 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -38,7 +38,7 @@ use ReturnTypeWillChange; /** - * @property static $type Repository type + * @property string $type */ abstract class Repository implements RestifySearchable, JsonSerializable { @@ -313,13 +313,13 @@ public function withResource($resource): self /** * Resolve repository with given model. * - * @param $model + * @param Model $model * @return Repository */ - public static function resolveWith($model): Repository + public static function resolveWith(Model $model): Repository { if (static::isMock()) { - return static::getMock()->withResource($model); + return static::getMock()?->withResource($model); } return resolve(static::class)->withResource($model); @@ -1012,7 +1012,7 @@ public function response($content = '', $status = 200, array $headers = []): Res public function serializeForShow(RestifyRequest $request): array { return $this->filter([ - 'id' => $this->when(optional($this->resource)->id, fn () => $this->getId($request)), + 'id' => $this->when(optional($this->resource)?->getKey(), fn () => $this->getId($request)), 'type' => $this->when($type = $this->getType($request), $type), 'attributes' => $request->isShowRequest() ? $this->resolveShowAttributes($request) : $this->resolveIndexAttributes($request), 'relationships' => $this->when(value($related = $this->resolveRelationships($request)), $related), diff --git a/src/Repositories/RepositoryEvents.php b/src/Repositories/RepositoryEvents.php index 0ebfff760..3d0601146 100644 --- a/src/Repositories/RepositoryEvents.php +++ b/src/Repositories/RepositoryEvents.php @@ -18,14 +18,14 @@ trait RepositoryEvents * * @var array */ - protected static $booted = []; + protected static array $booted = []; /** * Perform any actions required before the repository boots. * * @return void */ - protected static function booting() + protected static function booting(): void { // } @@ -35,7 +35,7 @@ protected static function booting() * * @return void */ - protected static function boot() + protected static function boot(): void { static::$relatedCast = app(config('restify.casts.related')); } @@ -45,12 +45,12 @@ protected static function boot() * * @return void */ - protected static function booted() + protected static function booted(): void { // } - protected function bootIfNotBooted() + protected function bootIfNotBooted(): void { if (! isset(static::$booted[static::class])) { static::$booted[static::class] = true; @@ -61,7 +61,7 @@ protected function bootIfNotBooted() } } - public static function mounting() + public static function mounting(): void { if (static::$prefix) { static::setPrefix(static::$prefix, static::uriKey()); @@ -73,11 +73,11 @@ public static function mounting() } /** - * Clear the list of booted repositories so they will be re-booted. + * Clear the list of booted repositories, so they will be re-booted. * * @return void */ - public static function clearBootedRepositories() + public static function clearBootedRepositories(): void { static::$booted = []; } diff --git a/src/Repositories/UserProfile.php b/src/Repositories/UserProfile.php index 9f74830be..49e6144bf 100644 --- a/src/Repositories/UserProfile.php +++ b/src/Repositories/UserProfile.php @@ -12,14 +12,14 @@ trait UserProfile public static $metaProfile = []; - public static function canUseForProfile(Request $request) + public static function canUseForProfile(Request $request): bool { return is_callable(static::$canUseForProfile) ? forward_static_call(static::$canUseForProfile, $request) : static::$canUseForProfile; } - public static function canUseForProfileUpdate(Request $request) + public static function canUseForProfileUpdate(Request $request): bool { return is_callable(static::$canUseForProfileUpdate) ? forward_static_call(static::$canUseForProfileUpdate, $request) diff --git a/src/Restify.php b/src/Restify.php index f4d744388..0cd0ef58d 100644 --- a/src/Restify.php +++ b/src/Restify.php @@ -5,6 +5,7 @@ use Binaryk\LaravelRestify\Bootstrap\BootRepository; use Binaryk\LaravelRestify\Events\RestifyBeforeEach; use Binaryk\LaravelRestify\Events\RestifyStarting; +use Binaryk\LaravelRestify\Exceptions\RepositoryNotFoundException; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Models\ActionLog; use Binaryk\LaravelRestify\Repositories\Repository; @@ -48,13 +49,34 @@ class Restify * @param string $key * @return string|null */ - public static function repositoryForKey(string $key): ?string + public static function repositoryClassForKey(string $key): ?string { return collect(static::$repositories)->first(function ($value) use ($key) { return $value::uriKey() === $key; }); } + /** + * Return the repository instance for a given key. + * + * @param string $key + * @throw RepositoryNotFoundException + * @return Repository + */ + public static function repository(string $key): Repository + { + /** + * @var Repository|string $repositoryClass + */ + if (is_null($repositoryClass = static::repositoryClassForKey($key))) { + throw RepositoryNotFoundException::make($repositoryClass); + } + + return $repositoryClass::isMock() + ? $repositoryClass::getMock() + : $repositoryClass::resolveWith($repositoryClass::newModel()); + } + /** * Get the repository class name for a given model. * diff --git a/src/Services/Search/GlobalSearch.php b/src/Services/Search/GlobalSearch.php index e980d8180..7227d8bcc 100644 --- a/src/Services/Search/GlobalSearch.php +++ b/src/Services/Search/GlobalSearch.php @@ -22,13 +22,6 @@ class GlobalSearch */ public $repositories; - /** - * Create a new global search instance. - * - * @param RestifyRequest $request - * @param \Illuminate\Support\Collection repositories - * @return void - */ public function __construct(RestifyRequest $request, $repositories) { $this->request = $request; @@ -44,7 +37,9 @@ public function get() { $formatted = []; - /** * @var Repository $repository */ + /** + * @var Repository $repository + */ foreach ($this->getSearchResults() as $repository => $models) { foreach ($models as $model) { $instance = $repository::resolveWith($model); @@ -55,7 +50,7 @@ public function get() 'title' => $instance->title(), 'subTitle' => $instance->subtitle(), 'repositoryId' => $model->getKey(), - 'link' => $repository::to($model->getKey()), + 'link' => $repository::route($model->getKey()), ]; } } diff --git a/src/Traits/AuthorizableModels.php b/src/Traits/AuthorizableModels.php index 9fb99a28c..0efcfc969 100644 --- a/src/Traits/AuthorizableModels.php +++ b/src/Traits/AuthorizableModels.php @@ -29,7 +29,7 @@ public static function authorizable() /** * Determine if the Restify is enabled for this repository. * - * @param \Illuminate\Http\Request $request + * @param Request $request * @return void * @throws AuthorizationException */ @@ -47,10 +47,10 @@ public function authorizeToUseRepository(Request $request) /** * Determine if the repository should be available for the given request. * - * @param \Illuminate\Http\Request $request + * @param Request $request * @return bool */ - public static function authorizedToUseRepository(Request $request) + public static function authorizedToUseRepository(Request $request): bool { if (! static::authorizable()) { return true; @@ -86,7 +86,7 @@ public function authorizedToShow(Request $request) /** * Determine if the current user can store new repositories or throw an exception. * - * @param \Illuminate\Http\Request $request + * @param Request $request * @return void * * @throws \Illuminate\Auth\Access\AuthorizationException @@ -108,7 +108,7 @@ public static function authorizeToStoreBulk(Request $request) /** * Determine if the current user can store new repositories. * - * @param \Illuminate\Http\Request $request + * @param Request $request * @return bool */ public static function authorizedToStore(Request $request) @@ -132,7 +132,7 @@ public static function authorizedToStoreBulk(Request $request) /** * Determine if the current user can update the given resource or throw an exception. * - * @param \Illuminate\Http\Request $request + * @param Request $request * @return void * * @throws \Illuminate\Auth\Access\AuthorizationException @@ -189,7 +189,7 @@ public function authorizeToDeleteBulk(Request $request) /** * Determine if the current user can update the given resource. * - * @param \Illuminate\Http\Request $request + * @param Request $request * @return bool */ public function authorizedToUpdate(Request $request) @@ -200,7 +200,7 @@ public function authorizedToUpdate(Request $request) /** * Determine if the current user can delete the given resource or throw an exception. * - * @param \Illuminate\Http\Request $request + * @param Request $request * @return void * * @throws \Illuminate\Auth\Access\AuthorizationException @@ -213,7 +213,7 @@ public function authorizeToDelete(Request $request) /** * Determine if the current user can delete the given resource. * - * @param \Illuminate\Http\Request $request + * @param Request $request * @return bool */ public function authorizedToDelete(Request $request) @@ -224,7 +224,7 @@ public function authorizedToDelete(Request $request) /** * Determine if the current user has a given ability. * - * @param \Illuminate\Http\Request $request + * @param Request $request * @param string $ability * @return void * @@ -240,7 +240,7 @@ public function authorizeTo(Request $request, $ability) /** * Determine if the current user can view the given resource. * - * @param \Illuminate\Http\Request $request + * @param Request $request * @param string $ability * @return bool */ diff --git a/src/Traits/InteractWithSearch.php b/src/Traits/InteractWithSearch.php index 4aa5e6eac..3b0ac95ec 100644 --- a/src/Traits/InteractWithSearch.php +++ b/src/Traits/InteractWithSearch.php @@ -34,14 +34,14 @@ public static function withs(): array return static::$withs ?? []; } - public static function related(): array + public static function include(): array { return static::$related ?? []; } public static function collectRelated(): RelatedCollection { - return RelatedCollection::make(static::related()); + return RelatedCollection::make(static::include()); } public static function matches(): array @@ -60,7 +60,7 @@ public static function sorts(): array public static function collectSorts(RestifyRequest $request, Repository $repository): SortCollection { - return SortCollection::make(explode(',', $request->input('sort', ''))) + return (new SortCollection(explode(',', $request->input('sort', '')))) ->normalize() ->hydrateDefinition($repository) ->authorized($request) @@ -70,7 +70,7 @@ public static function collectSorts(RestifyRequest $request, Repository $reposit public static function collectMatches(RestifyRequest $request, Repository $repository): MatchesCollection { - return MatchesCollection::make($repository::matches()) + return (new MatchesCollection($repository::matches())) ->normalize() ->authorized($request) ->inQuery($request) diff --git a/src/helpers.php b/src/helpers.php index 7de1a1f46..61552cc5d 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -3,6 +3,7 @@ use Binaryk\LaravelRestify\Fields\Field; use Binaryk\LaravelRestify\Restify; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; if (! function_exists('field')) { function field(...$args): Field @@ -12,7 +13,7 @@ function field(...$args): Field } if (! function_exists('isRestify')) { - function isRestify(\Illuminate\Http\Request $request): bool + function isRestify(Request $request): bool { return Restify::isRestify($request); } diff --git a/tests/Actions/FieldActionTest.php b/tests/Actions/FieldActionTest.php index 99d7d1499..3d37b6aa5 100644 --- a/tests/Actions/FieldActionTest.php +++ b/tests/Actions/FieldActionTest.php @@ -38,7 +38,7 @@ public function handle(RestifyRequest $request, Post $post) $this ->withoutExceptionHandling() - ->postJson(PostRepository::to(), [ + ->postJson(PostRepository::route(), [ 'description' => 'Description', 'title' => $updated = 'Title', ]) @@ -76,7 +76,7 @@ public function handle(RestifyRequest $request, Post $post, int $row) $this ->withoutExceptionHandling() - ->postJson(PostRepository::to('bulk'), [ + ->postJson(PostRepository::route('bulk'), [ [ 'title' => $title1 = 'First title', 'description' => 'first description', @@ -122,19 +122,19 @@ public function handle(RestifyRequest $request, Post $post, int $row) $postId1 = $this ->withoutExceptionHandling() - ->postJson(PostRepository::to(), [ + ->postJson(PostRepository::route(), [ 'title' => 'First title', ])->json('data.id'); $postId2 = $this ->withoutExceptionHandling() - ->postJson(PostRepository::to(), [ + ->postJson(PostRepository::route(), [ 'title' => 'Second title', ])->json('data.id'); $this ->withoutExceptionHandling() - ->postJson(PostRepository::to('bulk/update'), [ + ->postJson(PostRepository::route('bulk/update'), [ [ 'id' => $postId1, 'description' => 'first description', diff --git a/tests/Actions/ListActionsControllerTest.php b/tests/Actions/ListActionsControllerTest.php index 4e9bff153..e0e7d3804 100644 --- a/tests/Actions/ListActionsControllerTest.php +++ b/tests/Actions/ListActionsControllerTest.php @@ -13,7 +13,7 @@ public function test_could_list_actions_for_repository(): void { $_SERVER['actions.posts.invalidate'] = false; - $this->getJson(PostRepository::to('actions')) + $this->getJson(PostRepository::route('actions')) ->assertOk() ->assertJson( fn (AssertableJson $json) => $json diff --git a/tests/Actions/PerformActionControllerTest.php b/tests/Actions/PerformActionControllerTest.php index bfdb1720b..8aa78d0be 100644 --- a/tests/Actions/PerformActionControllerTest.php +++ b/tests/Actions/PerformActionControllerTest.php @@ -73,7 +73,7 @@ public function test_cannot_apply_a_show_action_to_index(): void ->assertNotFound(); } - public function test_show_action_not_need_repositories() + public function test_show_action_not_need_repositories(): void { $users = $this->mockUsers(); diff --git a/tests/Controllers/Index/RepositoryIndexControllerTest.php b/tests/Controllers/Index/RepositoryIndexControllerTest.php index 814bd9885..dcec14b0a 100644 --- a/tests/Controllers/Index/RepositoryIndexControllerTest.php +++ b/tests/Controllers/Index/RepositoryIndexControllerTest.php @@ -2,6 +2,7 @@ namespace Binaryk\LaravelRestify\Tests\Controllers\Index; +use Binaryk\LaravelRestify\Fields\HasMany; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Tests\Factories\PostFactory; @@ -12,6 +13,7 @@ use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; use Binaryk\LaravelRestify\Tests\Fixtures\Post\RelatedCastWithAttributes; use Binaryk\LaravelRestify\Tests\Fixtures\User\User; +use Binaryk\LaravelRestify\Tests\Fixtures\User\UserRepository; use Binaryk\LaravelRestify\Tests\IntegrationTest; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Request; @@ -28,39 +30,39 @@ public function it_can_paginate(): void PostRepository::$defaultPerPage = 5; - $this->getJson(PostRepository::to()) + $this->getJson(PostRepository::route()) ->assertJson( fn (AssertableJson $json) => $json - ->count('data', 5) - ->etc() + ->count('data', 5) + ->etc() ); - $this->getJson(PostRepository::to(null, [ + $this->getJson(PostRepository::route(null, [ 'perPage' => 10, ]))->assertJson( fn (AssertableJson $json) => $json - ->count('data', 10) - ->etc() + ->count('data', 10) + ->etc() ); - $this->getJson(PostRepository::to(null, [ + $this->getJson(PostRepository::route(null, [ 'page[size]' => 10, ]))->assertJson( fn (AssertableJson $json) => $json - ->count('data', 10) - ->etc() + ->count('data', 10) + ->etc() ); - $this->getJson(PostRepository::to(null, [ + $this->getJson(PostRepository::route(null, [ 'perPage' => 10, 'page' => '2', ]))->assertJson( fn (AssertableJson $json) => $json - ->count('data', 5) - ->etc() + ->count('data', 5) + ->etc() ); - $this->getJson(PostRepository::to(null, [ + $this->getJson(PostRepository::route(null, [ 'page[size]' => 10, 'page[number]' => 2, ]))->assertJson( @@ -91,7 +93,7 @@ public function it_can_search_using_query(): void PostRepository::$search = ['title']; - $this->getJson(PostRepository::to(null, [ + $this->getJson(PostRepository::route(null, [ 'search' => 'code', ]))->assertJson(fn (AssertableJson $json) => $json->count('data', 2)->etc()); } @@ -111,22 +113,22 @@ public function it_can_sort_using_query(): void 'title', ]; - $this->getJson(PostRepository::to(null, [ + $this->getJson(PostRepository::route(null, [ 'sort' => '-title', ]))->assertJson( fn (AssertableJson $json) => $json - ->where('data.0.attributes.title', 'ZZZ') - ->where('data.1.attributes.title', 'AAA') - ->etc() + ->where('data.0.attributes.title', 'ZZZ') + ->where('data.1.attributes.title', 'AAA') + ->etc() ); - $this->getJson(PostRepository::to(null, [ + $this->getJson(PostRepository::route(null, [ 'sort' => 'title', ]))->assertJson( fn (AssertableJson $json) => $json - ->where('data.0.attributes.title', 'AAA') - ->where('data.1.attributes.title', 'ZZZ') - ->etc() + ->where('data.0.attributes.title', 'AAA') + ->where('data.1.attributes.title', 'ZZZ') + ->etc() ); } @@ -143,12 +145,12 @@ public function it_can_return_related_entity(): void ]) )->create(); - $this->getJson(PostRepository::to(null, [ + $this->getJson(PostRepository::route(null, [ 'related' => 'user', ]))->assertJson( fn (AssertableJson $json) => $json - ->where('data.0.relationships.user.0.name', $name) - ->etc() + ->where('data.0.relationships.user.0.name', $name) + ->etc() ); } @@ -165,12 +167,12 @@ public function test_repository_can_resolve_related_using_callables(): void PostFactory::one(); - $this->getJson(PostRepository::to(null, [ + $this->getJson(PostRepository::route(null, [ 'related' => 'user', ]))->assertJson( fn (AssertableJson $json) => $json - ->where('data.0.relationships.user', 'foo') - ->etc() + ->where('data.0.relationships.user', 'foo') + ->etc() ); } @@ -185,12 +187,12 @@ public function it_can_transform_relationship_format_using_config(): void PostFactory::one(); - $this->getJson(PostRepository::to(null, [ + $this->getJson(PostRepository::route(null, [ 'related' => 'user', ]))->assertJson( fn (AssertableJson $json) => $json - ->has('data.0.relationships.user.0.attributes') - ->etc() + ->has('data.0.relationships.user.0.attributes') + ->etc() ); } @@ -198,9 +200,9 @@ public function it_can_transform_relationship_format_using_config(): void public function it_can_retrieve_nested_relationships(): void { CompanyRepository::partialMock() - ->shouldReceive('related') + ->shouldReceive('include') ->andReturn([ - 'users.posts', + 'users' => HasMany::make('users', UserRepository::class), ]); Company::factory()->has( @@ -209,16 +211,14 @@ public function it_can_retrieve_nested_relationships(): void ) )->create(); - $response = $this->getJson(CompanyRepository::to(null, [ - 'related' => 'users.posts', + $this->getJson(CompanyRepository::route(null, [ + 'related' => 'users', ]))->assertJson( fn (AssertableJson $json) => $json - ->has('data.0.relationships') - ->etc() + ->has('data.0.relationships') + ->has('data.0.relationships.users') + ->etc() ); - - self::assertCount(1, $response->json('data.0.relationships')['users.posts']); - self::assertCount(1, $response->json('data.0.relationships')['users.posts'][0]['posts']); } /** * @test */ @@ -238,7 +238,7 @@ public function it_can_paginate_keeping_relationships(): void 'name' => $owner = 'John Doe', ]))->create(); - $this->getJson(PostRepository::to(null, [ + $this->getJson(PostRepository::route(null, [ 'perPage' => 5, 'related' => 'user', 'sort' => 'id', @@ -246,9 +246,9 @@ public function it_can_paginate_keeping_relationships(): void ])) ->assertJson( fn (AssertableJson $json) => $json - ->count('data', 1) - ->where('data.0.relationships.user.0.name', $owner) - ->etc() + ->count('data', 1) + ->where('data.0.relationships.user.0.name', $owner) + ->etc() ); } @@ -256,7 +256,7 @@ public function test_index_unmergeable_repository_contains_only_explicitly_defin { PostFactory::one(); - $response = $this->getJson(PostRepository::to()) + $response = $this->getJson(PostRepository::route()) ->assertOk() ->assertJsonStructure([ 'data' => [ @@ -279,7 +279,7 @@ public function test_index_mergeable_repository_contains_model_attributes_and_lo PostMergeableRepository::class, ]); - $this->getJson(PostMergeableRepository::to( + $this->getJson(PostMergeableRepository::route( $this->mockPost()->id ))->assertJsonStructure([ 'data' => [ @@ -299,7 +299,7 @@ public function test_can_add_custom_index_main_meta_attributes(): void 'title' => 'Post Title', ]); - $response = $this->getJson(PostRepository::to()) + $response = $this->getJson(PostRepository::route()) ->assertJsonStructure([ 'meta' => [ 'postKey', diff --git a/tests/Controllers/ProfileControllerTest.php b/tests/Controllers/ProfileControllerTest.php index 25b2f6b46..6ea5e8b6d 100644 --- a/tests/Controllers/ProfileControllerTest.php +++ b/tests/Controllers/ProfileControllerTest.php @@ -103,8 +103,8 @@ public function test_profile_validation_from_repository(): void ->assertStatus(422) ->assertJson( fn (AssertableJson $json) => $json - ->has('message') - ->has('errors') + ->has('message') + ->has('errors') ); } @@ -116,9 +116,9 @@ public function test_get_profile_can_use_repository(): void ->assertOk() ->assertJson( fn (AssertableJson $json) => $json - ->has('data') - ->where('data.attributes.email', $this->authenticatedAs->email) - ->etc() + ->has('data') + ->where('data.attributes.email', $this->authenticatedAs->email) + ->etc() ); } @@ -130,11 +130,11 @@ public function test_profile_returns_authenticated_user_with_related_posts_via_r ->assertOk() ->assertJson( fn (AssertableJson $json) => $json - ->has('data') - ->has('data.attributes') - ->has('data.relationships.posts') - ->where('data.attributes.email', $this->authenticatedAs->email) - ->etc() + ->has('data') + ->has('data.attributes') + ->has('data.relationships.posts') + ->where('data.attributes.email', $this->authenticatedAs->email) + ->etc() ); } @@ -149,9 +149,9 @@ public function test_profile_returns_authenticated_user_with_meta_profile_data_v $this->getJson('profile') ->assertJson( fn (AssertableJson $json) => $json - ->has('data.attributes') - ->has('data.meta.roles') - ->etc() + ->has('data.attributes') + ->has('data.meta.roles') + ->etc() ); } @@ -164,7 +164,7 @@ public function test_profile_update_via_repository(): void ]) ->assertJson( fn (AssertableJson $json) => $json - ->where('data.attributes.email', $email) + ->where('data.attributes.email', $email) ); } diff --git a/tests/Controllers/RepositoryAttachControllerTest.php b/tests/Controllers/RepositoryAttachControllerTest.php index 6800cae0e..6ce4ad6fd 100644 --- a/tests/Controllers/RepositoryAttachControllerTest.php +++ b/tests/Controllers/RepositoryAttachControllerTest.php @@ -25,12 +25,12 @@ public function test_can_attach_repositories(): void $this->assertCount(0, Company::first()->users); $this->postJson('companies/'.$company->id.'/attach/users', [ - 'users' => $user->id, + 'users' => $user->getKey(), 'is_admin' => true, ]) ->assertCreated()->assertJsonFragment([ 'company_id' => '1', - 'user_id' => $user->id, + 'user_id' => $user->getKey(), 'is_admin' => true, ]); @@ -51,14 +51,14 @@ public function test_cant_attach_repositories_not_authorized_to_attach(): void $_SERVER['allow_attach_users'] = false; $this->postJson('companies/'.$company->id.'/attach/users', [ - 'users' => $user->id, + 'users' => $user->getKey(), 'is_admin' => true, ])->assertForbidden(); $_SERVER['allow_attach_users'] = true; $this->postJson('companies/'.$company->id.'/attach/users', [ - 'users' => $user->id, + 'users' => $user->getKey(), 'is_admin' => true, ])->assertCreated(); @@ -71,7 +71,7 @@ public function test_attach_pivot_field_validation(): void $company = Company::factory()->create(); CompanyRepository::partialMock() - ->shouldReceive('related') + ->shouldReceive('include') ->andReturn([ 'users' => BelongsToMany::make('users', UserRepository::class)->withPivot( Field::make('is_admin')->rules('required')->messages([ @@ -81,7 +81,7 @@ public function test_attach_pivot_field_validation(): void ]); $this->postJson('companies/'.$company->id.'/attach/users', [ - 'users' => $user->id, + 'users' => $user->getKey(), ])->assertStatus(422)->assertJsonFragment([ 'is_admin' => [ $message, @@ -146,7 +146,7 @@ public function test_attach_multiple_users_to_a_company(): void 'is_admin' => true, ])->assertCreated()->assertJsonFragment([ 'company_id' => '1', - 'user_id' => $user->id, + 'user_id' => $user->getKey(), 'is_admin' => true, ]); @@ -159,7 +159,7 @@ public function test_many_to_many_field_can_intercept_attach_authorization(): vo $company = Company::factory()->create(); CompanyRepository::partialMock() - ->shouldReceive('related') + ->shouldReceive('include') ->andReturn([ 'users' => BelongsToMany::make('users', UserRepository::class) ->canAttach(function ($request, $pivot) { @@ -171,7 +171,7 @@ public function test_many_to_many_field_can_intercept_attach_authorization(): vo ]); $this->postJson('companies/'.$company->id.'/attach/users', [ - 'users' => $user->id, + 'users' => $user->getKey(), 'is_admin' => true, ])->assertForbidden(); } @@ -182,7 +182,7 @@ public function test_many_to_many_field_can_intercept_attach_method(): void $company = Company::factory()->create(); CompanyRepository::partialMock() - ->shouldReceive('related') + ->shouldReceive('include') ->andReturn([ 'users' => BelongsToMany::make('users', UserRepository::class) ->canAttach(function ($request, $pivot) { @@ -195,7 +195,7 @@ public function test_many_to_many_field_can_intercept_attach_method(): void ]); $this->postJson('companies/'.$company->id.'/attach/users', [ - 'users' => $user->id, + 'users' => $user->getKey(), 'is_admin' => true, ])->assertOk(); @@ -207,7 +207,7 @@ public function test_repository_can_intercept_attach(): void $user = $this->mockUsers()->first(); $company = Company::factory()->create(); - CompanyRepository::partialMock()->shouldReceive('related') + CompanyRepository::partialMock()->shouldReceive('include') ->andReturn([ 'users' => BelongsToMany::make('users', UserRepository::class), ]); @@ -223,7 +223,7 @@ public function test_repository_can_intercept_attach(): void ]; $this->postJson('companies/'.$company->id.'/attach/users', [ - 'users' => $user->id, + 'users' => $user->getKey(), ])->assertOk(); $this->assertCount(1, $company->fresh()->users); diff --git a/tests/Controllers/RepositoryDestroyControllerTest.php b/tests/Controllers/RepositoryDestroyControllerTest.php index e92b8d10f..461e26e46 100644 --- a/tests/Controllers/RepositoryDestroyControllerTest.php +++ b/tests/Controllers/RepositoryDestroyControllerTest.php @@ -39,7 +39,7 @@ public function test_unauthorized_to_destroy(): void $_SERVER['restify.post.delete'] = false; - $this->deleteJson(PostRepository::to($post->id))->assertStatus(403); + $this->deleteJson(PostRepository::route($post->id))->assertStatus(403); $this->assertInstanceOf(Post::class, $post->refresh()); } diff --git a/tests/Controllers/RepositoryDetachControllerTest.php b/tests/Controllers/RepositoryDetachControllerTest.php index edebf67ad..09994e024 100644 --- a/tests/Controllers/RepositoryDetachControllerTest.php +++ b/tests/Controllers/RepositoryDetachControllerTest.php @@ -65,7 +65,7 @@ public function test_cant_detach_repositories_not_authorized_to_detach() public function test_many_to_many_field_can_intercept_detach_authorization() { CompanyRepository::partialMock() - ->shouldReceive('related') + ->shouldReceive('include') ->andReturn([ 'users' => BelongsToMany::make('users', UserRepository::class)->canDetach(function ($request, $pivot) { $this->assertInstanceOf(Request::class, $request); @@ -87,7 +87,7 @@ public function test_many_to_many_field_can_intercept_detach_authorization() public function test_many_to_many_field_can_intercept_detach_method() { CompanyRepository::partialMock() - ->shouldReceive('related') + ->shouldReceive('include') ->andReturn([ 'users' => BelongsToMany::make('users', UserRepository::class)->detachCallback(function ($request, $repository, $model) { $this->assertInstanceOf(Request::class, $request); @@ -114,7 +114,7 @@ public function test_many_to_many_field_can_intercept_detach_method() public function test_repository_can_intercept_detach() { $mock = CompanyRepository::partialMock(); - $mock->shouldReceive('related') + $mock->shouldReceive('include') ->andReturn([ 'users' => BelongsToMany::make('users', UserRepository::class), ]); diff --git a/tests/Controllers/RepositoryMiddlewaresTest.php b/tests/Controllers/RepositoryMiddlewaresTest.php index fc7eccafa..0756317f4 100644 --- a/tests/Controllers/RepositoryMiddlewaresTest.php +++ b/tests/Controllers/RepositoryMiddlewaresTest.php @@ -25,7 +25,7 @@ function () { }, ]; - $this->getJson(PostRepository::to())->assertNotFound(); + $this->getJson(PostRepository::route())->assertNotFound(); } public function test_foreign_repository_middleware_should_not_be_invoked(): void @@ -42,7 +42,7 @@ public function test_foreign_repository_middleware_should_not_be_invoked(): void UserRepository::class, ]); - $this->getJson(PostRepository::to())->assertOk(); + $this->getJson(PostRepository::route())->assertOk(); UserRepository::$middleware = []; } diff --git a/tests/Controllers/RepositoryPatchControllerTest.php b/tests/Controllers/RepositoryPatchControllerTest.php index 703ef9d02..695c10225 100644 --- a/tests/Controllers/RepositoryPatchControllerTest.php +++ b/tests/Controllers/RepositoryPatchControllerTest.php @@ -28,7 +28,7 @@ public function test_partial_update_doesnt_validate_other_fields(): void field('description')->rules('required'), ]); - $this->patchJson(PostRepository::to($post->id), [ + $this->patchJson(PostRepository::route($post->id), [ 'title' => 'Updated title.', ])->assertOk(); diff --git a/tests/Controllers/RepositoryShowControllerTest.php b/tests/Controllers/RepositoryShowControllerTest.php index 508535e9e..77a02f277 100644 --- a/tests/Controllers/RepositoryShowControllerTest.php +++ b/tests/Controllers/RepositoryShowControllerTest.php @@ -21,7 +21,7 @@ protected function setUp(): void public function test_basic_show(): void { - $this->getJson(PostRepository::to(1)) + $this->getJson(PostRepository::route(1)) ->assertOk() ->assertJsonStructure([ 'data' => [ @@ -44,7 +44,7 @@ public function test_show_will_authorize_fields(): void field('description')->hidden(), ]); - $this->getJson(PostRepository::to(1)) + $this->getJson(PostRepository::route(1)) ->assertJson( fn (AssertableJson $json) => $json ->missing('data.attributes.title') @@ -53,7 +53,7 @@ public function test_show_will_authorize_fields(): void $_SERVER['postAuthorize.can.see.title'] = true; - $this->getJson(PostRepository::to(1)) + $this->getJson(PostRepository::route(1)) ->assertJson( fn (AssertableJson $json) => $json ->has('data.attributes.title') @@ -69,7 +69,7 @@ public function test_show_will_take_into_consideration_show_callback(): void field('title')->showCallback(fn ($value) => strtoupper($value)), ]); - $this->getJson(PostRepository::to( + $this->getJson(PostRepository::route( $this->mockPost([ 'title' => 'wew', ])->id @@ -86,7 +86,7 @@ public function test_show_merge_able_repository_contains_model_attributes_and_lo PostMergeableRepository::class, ]); - $this->getJson(PostMergeableRepository::to( + $this->getJson(PostMergeableRepository::route( $this->mockPost()->id )) ->assertJsonStructure([ @@ -114,7 +114,7 @@ public function test_repository_hidden_fields_are_not_visible(): void field('description')->hidden(), ]); - $this->getJson(PostRepository::to( + $this->getJson(PostRepository::route( $this->mockPosts()->first()->id )) ->assertJson( @@ -135,7 +135,7 @@ public function test_repository_hidden_fields_could_not_be_updated(): void field('description')->hidden(), ]); - $this->putJson(PostRepository::to( + $this->putJson(PostRepository::route( $post = $this->mockPost(['description' => 'Description'])->id ), [ 'title' => $title = 'Updated title', @@ -164,7 +164,7 @@ public function test_repository_hidden_fields_could_be_updated_through_value(): field('description')->hidden()->value($default = 'Default description'), ]); - $this->putJson(PostRepository::to( + $this->putJson(PostRepository::route( $post = $this->mockPost()->id ), [ 'title' => 'Updated title', diff --git a/tests/Controllers/RepositoryStoreBulkControllerTest.php b/tests/Controllers/RepositoryStoreBulkControllerTest.php index 0ae0253b3..f8d59ff86 100644 --- a/tests/Controllers/RepositoryStoreBulkControllerTest.php +++ b/tests/Controllers/RepositoryStoreBulkControllerTest.php @@ -44,11 +44,11 @@ public function test_user_can_bulk_create_posts(): void $user = $this->mockUsers()->first(); $this->postJson('posts/bulk', [ [ - 'user_id' => $user->id, + 'user_id' => $user->getKey(), 'title' => 'First post.', ], [ - 'user_id' => $user->id, + 'user_id' => $user->getKey(), 'title' => 'Second post.', ], ])->assertSuccessful(); diff --git a/tests/Controllers/RepositoryStoreControllerTest.php b/tests/Controllers/RepositoryStoreControllerTest.php index 25a618b51..0298c17c1 100644 --- a/tests/Controllers/RepositoryStoreControllerTest.php +++ b/tests/Controllers/RepositoryStoreControllerTest.php @@ -58,7 +58,7 @@ public function test_will_store_only_defined_fields_from_fieldsForStore(): void { $user = $this->mockUsers()->first(); $response = $this->postJson('posts', [ - 'user_id' => $user->id, + 'user_id' => $user->getKey(), 'title' => 'Some post title', 'description' => 'A very short description', ]) @@ -79,7 +79,7 @@ public function test_cannot_store_unauthorized_fields(): void Field::new('description')->canStore(fn () => false), ]); - $this->postJson(PostRepository::to(), [ + $this->postJson(PostRepository::route(), [ 'description' => 'Description', 'title' => $updated = 'Title', ]) @@ -101,7 +101,7 @@ public function test_cannot_store_readonly_fields(): void Field::new('description')->readonly(), ]); - $this->postJson(PostRepository::to(), [ + $this->postJson(PostRepository::route(), [ 'description' => 'Description', 'title' => $updated = 'Title', ]) diff --git a/tests/Controllers/RepositoryUpdateBulkControllerTest.php b/tests/Controllers/RepositoryUpdateBulkControllerTest.php index 48be159d4..60d79a4fe 100644 --- a/tests/Controllers/RepositoryUpdateBulkControllerTest.php +++ b/tests/Controllers/RepositoryUpdateBulkControllerTest.php @@ -22,7 +22,7 @@ public function test_basic_update_validation_works(): void 'title' => 'First title', ]); - $this->postJson(PostRepository::to('bulk/update'), [ + $this->postJson(PostRepository::route('bulk/update'), [ [ 'id' => $post->id, 'title' => null, diff --git a/tests/Controllers/RepositoryUpdateControllerTest.php b/tests/Controllers/RepositoryUpdateControllerTest.php index 4d4459e80..15bafd641 100644 --- a/tests/Controllers/RepositoryUpdateControllerTest.php +++ b/tests/Controllers/RepositoryUpdateControllerTest.php @@ -65,7 +65,7 @@ public function test_cannot_update_unauthorized_fields(): void Field::new('title'), ]); - $this->putJson(PostRepository::to(Post::factory()->create([ + $this->putJson(PostRepository::route(Post::factory()->create([ 'image' => null, 'title' => 'Initial', ])->id), [ @@ -90,7 +90,7 @@ public function test_cannot_update_readonly_fields(): void Field::new('title'), ]); - $this->putJson(PostRepository::to(Post::factory()->create([ + $this->putJson(PostRepository::route(Post::factory()->create([ 'image' => null, 'title' => 'Initial', ])->id), [ diff --git a/tests/Factories/UserFactory.php b/tests/Factories/UserFactory.php index fb87f8fc5..2b8dfaad9 100644 --- a/tests/Factories/UserFactory.php +++ b/tests/Factories/UserFactory.php @@ -10,13 +10,16 @@ class UserFactory extends Factory { protected $model = User::class; - public function definition() + public function definition(): array { return [ 'name' => $this->faker->name, 'email' => $this->faker->unique()->safeEmail, + 'active' => $this->faker->boolean, 'password' => '$2y$10$TKh8H1.PfQx37YgCzwiKb.KjNyWgaHb9cbcoQgdIVFlYg7B77UdFm', 'remember_token' => Str::random(10), + 'created_at' => now(), + 'updated_at' => now(), ]; } diff --git a/tests/Feature/Filters/AdvancedFilterTest.php b/tests/Feature/Filters/AdvancedFilterTest.php index 62734bbbf..061f53826 100644 --- a/tests/Feature/Filters/AdvancedFilterTest.php +++ b/tests/Feature/Filters/AdvancedFilterTest.php @@ -96,7 +96,7 @@ public function test_select_filter_validates_payload(): void ], ], JSON_THROW_ON_ERROR)); - $this->getJson(PostRepository::to(null, ['filters' => $filters])) + $this->getJson(PostRepository::route(null, ['filters' => $filters])) ->assertStatus(422); $filters = base64_encode(json_encode([ @@ -114,7 +114,7 @@ public function test_select_filter_validates_payload(): void ], ], JSON_THROW_ON_ERROR)); - $this->getJson(PostRepository::to(null, ['filters' => $filters])) + $this->getJson(PostRepository::route(null, ['filters' => $filters])) ->assertJsonCount(0, 'data'); } @@ -160,7 +160,7 @@ public function test_the_select_filter_is_applied(): void ], ], JSON_THROW_ON_ERROR)); - $this->getJson(PostRepository::to(null, ['filters' => $filters])) + $this->getJson(PostRepository::route(null, ['filters' => $filters])) ->assertOk() ->assertJsonCount(1, 'data'); } diff --git a/tests/Feature/Filters/MatchFilterTest.php b/tests/Feature/Filters/MatchFilterTest.php index e8a0d2a57..cbacba5ef 100644 --- a/tests/Feature/Filters/MatchFilterTest.php +++ b/tests/Feature/Filters/MatchFilterTest.php @@ -2,13 +2,36 @@ namespace Binaryk\LaravelRestify\Tests\Feature\Filters; +use Binaryk\LaravelRestify\Contracts\RestifySearchable; use Binaryk\LaravelRestify\Filters\MatchFilter; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; +use Binaryk\LaravelRestify\Tests\Fixtures\User\User; use Binaryk\LaravelRestify\Tests\Fixtures\User\UserRepository; use Binaryk\LaravelRestify\Tests\IntegrationTest; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\Request; +use Illuminate\Testing\Fluent\AssertableJson; class MatchFilterTest extends IntegrationTest { + public function test_matchable_filter_has_key(): void + { + $filter = new class extends MatchFilter { + public ?string $column = 'approved_at'; + }; + + tap( + AssertableJson::fromArray($filter->jsonSerialize()), + function (AssertableJson $json) { + $json + ->where('key', 'matches') + ->where('title', 'Approved At') + ->where('column', 'approved_at') + ->etc(); + } + ); + } + public function test_match_definitions_includes_title(): void { PostRepository::$match = [ @@ -33,4 +56,170 @@ public function test_match_definitions_includes_title(): void ], ]); } + + public function test_match_definition_hit_filter_method(): void + { + User::factory(4)->create(); + + UserRepository::$match = [ + 'id' => MatchFilter::make()->setType(RestifySearchable::MATCH_ARRAY), + ]; + + $this->getJson('users?-id=1,2,3') + ->assertJsonCount(1, 'data'); + + UserRepository::$match = [ + 'id' => MatchFilter::make()->setType(RestifySearchable::MATCH_ARRAY), + ]; + + $this->getJson('users?id=1,2,3') + ->assertJsonCount(3, 'data'); + } + + public function test_match_partially(): void + { + User::factory(2)->create([ + 'name' => 'John Doe', + ]); + + UserRepository::$match = [ + 'name' => MatchFilter::make()->setType(RestifySearchable::MATCH_TEXT)->strict(), + ]; + + $this->getJson('users?name=John')->assertJsonCount(0, 'data'); + + UserRepository::$match = [ + 'name' => MatchFilter::make()->setType(RestifySearchable::MATCH_TEXT)->strict(), + ]; + + $this->getJson('users?-name=John')->assertJsonCount(2, 'data'); + + UserRepository::$match = [ + 'name' => MatchFilter::make()->setType(RestifySearchable::MATCH_TEXT)->partial(), + ]; + + $this->getJson('users?name=John')->assertJsonCount(2, 'data'); + + UserRepository::$match = [ + 'name' => MatchFilter::make()->setType(RestifySearchable::MATCH_TEXT)->partial(), + ]; + $this->getJson('users?-name=John')->assertJsonCount(0, 'data'); + } + + public function test_can_match_range(): void + { + User::factory(4)->create(); + + UserRepository::$match = [ + 'id' => RestifySearchable::MATCH_BETWEEN, + ]; + + $this->getJson('users?id=1,3') + ->assertJsonCount(3, 'data'); + } + + public function test_can_match_using_json_api_recommendation(): void + { + User::factory(4)->create(); + + UserRepository::$match = [ + 'id' => RestifySearchable::MATCH_ARRAY, + ]; + + $this->getJson('users?filter[id]=1,2,3') + ->assertJsonCount(3, 'data'); + + $this->getJson('users?filter[-id]=1,2,3') + ->assertJsonCount(1, 'data'); + } + + public function test_can_match_array(): void + { + User::factory(4)->create(); + + UserRepository::$match = [ + 'id' => RestifySearchable::MATCH_ARRAY, + ]; + + $this->getJson('users?id=1,2,3') + ->assertJsonCount(3, 'data'); + + $this->getJson('users?-id=1,2,3') + ->assertJsonCount(1, 'data'); + } + + public function test_can_match_date(): void + { + User::factory(2)->create([ + 'created_at' => null, + ]); + + User::factory(3)->create([ + 'created_at' => '01-12-2020', + ]); + + UserRepository::$match = [ + 'created_at' => RestifySearchable::MATCH_DATETIME, + ]; + + $this->getJson('users?created_at=null')->assertJsonCount(2, 'data'); + + $this->getJson('users?created_at=2020-12-01')->assertJsonCount(3, 'data'); + } + + public function test_can_match_datetime_interval(): void + { + User::factory()->state([ + 'created_at' => now()->subMonth(), + ])->create(); + + User::factory()->state([ + 'created_at' => now()->subWeek(), + ])->create(); + + User::factory()->state([ + 'created_at' => now()->addMonth(), + ])->create(); + + UserRepository::$match = [ + 'created_at' => RestifySearchable::MATCH_DATETIME, + ]; + + $now = now()->toISOString(); + $twoMonthsAgo = now()->subMonths(2)->toISOString(); + + $this->getJson("users?created_at=$twoMonthsAgo,$now") + ->assertJsonCount(2, 'data'); + + $this->getJson("users?-created_at=$twoMonthsAgo,$now") + ->assertJsonCount(1, 'data'); + } + + public function test_can_match_closure(): void + { + User::factory(4)->state([ + 'active' => false, + ])->create(); + + User::factory()->state([ + 'active' => true, + ])->create(); + + UserRepository::$match = [ + 'is_active' => function ($request, $query) { + $this->assertInstanceOf(Request::class, $request); + $this->assertInstanceOf(Builder::class, $query); + + return $query->where('active', true); + }, + ]; + + $this + ->getJson('users?is_active=true') + ->assertJson( + fn (AssertableJson $json) => $json + ->count('data', 1) + ->etc() + ); + } } diff --git a/tests/Feature/Filters/SortableFilterTest.php b/tests/Feature/Filters/SortableFilterTest.php index 3a862c5ad..e9edccde7 100644 --- a/tests/Feature/Filters/SortableFilterTest.php +++ b/tests/Feature/Filters/SortableFilterTest.php @@ -2,8 +2,35 @@ namespace Binaryk\LaravelRestify\Tests\Feature\Filters; +use Binaryk\LaravelRestify\Filters\SortableFilter; +use Binaryk\LaravelRestify\Tests\Fixtures\User\User; +use Binaryk\LaravelRestify\Tests\Fixtures\User\UserRepository; use Binaryk\LaravelRestify\Tests\IntegrationTest; class SortableFilterTest extends IntegrationTest { + public function test_can_order_using_filter_sortable_definition(): void + { + User::factory()->create([ + 'name' => 'Zoro', + ]); + + User::factory()->create([ + 'name' => 'Alisa', + ]); + + UserRepository::$sort = [ + 'name' => SortableFilter::make()->setColumn('name'), + ]; + + $this->assertSame('Alisa', $this->getJson('users?sort=name') + ->json('data.0.attributes.name')); + + $this->assertSame('Zoro', $this->getJson('users?sort=name') + ->json('data.1.attributes.name')); + $this->assertSame('Zoro', $this->getJson('users?sort=-name') + ->json('data.0.attributes.name')); + $this->assertSame('Alisa', $this->getJson('users?sort=-name') + ->json('data.1.attributes.name')); + } } diff --git a/tests/Feature/RepositorySearchServiceTest.php b/tests/Feature/RepositorySearchServiceTest.php index ae0b218d8..7e54343ec 100644 --- a/tests/Feature/RepositorySearchServiceTest.php +++ b/tests/Feature/RepositorySearchServiceTest.php @@ -2,11 +2,8 @@ namespace Binaryk\LaravelRestify\Tests\Feature; -use Binaryk\LaravelRestify\Contracts\RestifySearchable; use Binaryk\LaravelRestify\Fields\BelongsTo; -use Binaryk\LaravelRestify\Filters\MatchFilter; use Binaryk\LaravelRestify\Filters\SearchableFilter; -use Binaryk\LaravelRestify\Filters\SortableFilter; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; @@ -14,156 +11,9 @@ use Binaryk\LaravelRestify\Tests\Fixtures\User\UserRepository; use Binaryk\LaravelRestify\Tests\Fixtures\User\VerifiedMatcher; use Binaryk\LaravelRestify\Tests\IntegrationTest; -use Illuminate\Database\Query\Builder; -use Illuminate\Http\Request; class RepositorySearchServiceTest extends IntegrationTest { - public function test_can_match_date(): void - { - User::factory(2)->create([ - 'created_at' => null, - ]); - - User::factory(3)->create([ - 'created_at' => '01-12-2020', - ]); - - UserRepository::$match = [ - 'created_at' => RestifySearchable::MATCH_DATETIME, - ]; - - $this->getJson('users?created_at=null')->assertJsonCount(2, 'data'); - - $this->getJson('users?created_at=2020-12-01')->assertJsonCount(3, 'data'); - } - - public function test_can_match_array(): void - { - User::factory(4)->create(); - - UserRepository::$match = [ - 'id' => RestifySearchable::MATCH_ARRAY, - ]; - - $this->getJson('users?id=1,2,3') - ->assertJsonCount(3, 'data'); - - $this->getJson('users?-id=1,2,3') - ->assertJsonCount(1, 'data'); - } - - public function test_can_match_using_json_api_recommendation(): void - { - User::factory(4)->create(); - - UserRepository::$match = [ - 'id' => RestifySearchable::MATCH_ARRAY, - ]; - - $this->getJson('users?filter[id]=1,2,3') - ->assertJsonCount(3, 'data'); - - $this->getJson('users?filter[-id]=1,2,3') - ->assertJsonCount(1, 'data'); - } - - public function test_can_match_range(): void - { - User::factory(4)->create(); - - UserRepository::$match = [ - 'id' => RestifySearchable::MATCH_BETWEEN, - ]; - - $this->getJson('users?id=1,3') - ->assertJsonCount(3, 'data'); - } - - public function test_can_match_datetime_interval(): void - { - $user = User::factory()->create(); - - $user->forceFill([ - 'created_at' => now()->subMonth(), - ]); - $user->save(); - $user = User::factory()->create(); - - $user->forceFill([ - 'created_at' => now()->subWeek(), - ]); - $user->save(); - - $user = User::factory()->create(); - - $user->forceFill([ - 'created_at' => now()->addMonth(), - ]); - $user->save(); - - UserRepository::$match = [ - 'created_at' => RestifySearchable::MATCH_BETWEEN, - ]; - - $twoMonthsAgo = now()->subMonths(2)->toISOString(); - $now = now()->toISOString(); - $this->getJson("users?created_at={$twoMonthsAgo},{$now}") - ->assertJsonCount(2, 'data'); - - $this->getJson("users?-created_at={$twoMonthsAgo},{$now}") - ->assertJsonCount(1, 'data'); - } - - public function test_match_definition_hit_filter_method(): void - { - User::factory(4)->create(); - - UserRepository::$match = [ - 'id' => MatchFilter::make()->setType(RestifySearchable::MATCH_ARRAY), - ]; - - $this->getJson('users?-id=1,2,3') - ->assertJsonCount(1, 'data'); - - UserRepository::$match = [ - 'id' => MatchFilter::make()->setType(RestifySearchable::MATCH_ARRAY), - ]; - - $this->getJson('users?id=1,2,3') - ->assertJsonCount(3, 'data'); - } - - public function test_match_partially(): void - { - User::factory(2)->create([ - 'name' => 'John Doe', - ]); - - UserRepository::$match = [ - 'name' => MatchFilter::make()->setType(RestifySearchable::MATCH_TEXT)->strict(), - ]; - - $this->getJson('users?name=John')->assertJsonCount(0, 'data'); - - UserRepository::$match = [ - 'name' => MatchFilter::make()->setType(RestifySearchable::MATCH_TEXT)->strict(), - ]; - - $this->getJson('users?-name=John')->assertJsonCount(2, 'data'); - - UserRepository::$match = [ - 'name' => MatchFilter::make()->setType(RestifySearchable::MATCH_TEXT)->partial(), - ]; - - $this->getJson('users?name=John')->assertJsonCount(2, 'data'); - - UserRepository::$match = [ - 'name' => MatchFilter::make()->setType(RestifySearchable::MATCH_TEXT)->partial(), - ]; - $this->getJson('users?-name=John')->assertJsonCount(0, 'data'); - } - public function test_can_search_using_filter_searchable_definition(): void { User::factory(4)->create([ @@ -228,45 +78,6 @@ public function test_can_search_using_belongs_to_field(): void ->assertJsonCount(2, 'data'); } - public function test_can_order_using_filter_sortable_definition(): void - { - User::factory()->create([ - 'name' => 'Zoro', - ]); - - User::factory()->create([ - 'name' => 'Alisa', - ]); - - UserRepository::$sort = [ - 'name' => SortableFilter::make()->setColumn('name'), - ]; - - $this->assertSame('Alisa', $this->getJson('users?sort=name') - ->json('data.0.attributes.name')); - - $this->assertSame('Zoro', $this->getJson('users?sort=name') - ->json('data.1.attributes.name')); - $this->assertSame('Zoro', $this->getJson('users?sort=-name') - ->json('data.0.attributes.name')); - $this->assertSame('Alisa', $this->getJson('users?sort=-name') - ->json('data.1.attributes.name')); - } - - public function test_can_match_closure(): void - { - User::factory(4)->create(); - - UserRepository::$match = [ - 'is_active' => function ($request, $query) { - $this->assertInstanceOf(Request::class, $request); - $this->assertInstanceOf(Builder::class, $query); - }, - ]; - - $this->getJson('users?is_active=true'); - } - public function test_can_match_custom_matcher(): void { User::factory(1)->create([ diff --git a/tests/Fields/BelongsToFieldTest.php b/tests/Fields/BelongsToFieldTest.php index dda664634..6e6cf59b7 100644 --- a/tests/Fields/BelongsToFieldTest.php +++ b/tests/Fields/BelongsToFieldTest.php @@ -44,12 +44,12 @@ public function test_present_on_show_when_specified_related(): void $post = PostFactory::one(); PostRepository::partialMock() - ->shouldReceive('related') + ->shouldReceive('include') ->andReturn([ 'user' => BelongsTo::make('user', UserRepository::class), ]); - $this->getJson(PostRepository::to($post->id, [ + $this->getJson(PostRepository::route($post->id, [ 'related' => 'user', ])) ->assertJsonStructure([ @@ -64,7 +64,7 @@ public function test_present_on_show_when_specified_related(): void ], ]); - $relationships = $this->getJson(PostRepository::to($post->id)) + $relationships = $this->getJson(PostRepository::route($post->id)) ->json('data.relationships'); $this->assertNull($relationships); @@ -106,7 +106,7 @@ public function test_field_used_when_storing() tap(User::factory()->create(), function ($user) { $this->postJson(PostWithUserRepository::uriKey(), [ 'title' => 'Create post with owner.', - 'user' => $user->id, + 'user' => $user->getKey(), ])->assertCreated(); }); } @@ -130,7 +130,7 @@ public function test_unauthorized_via_callback_models_cannot_be_attached(): void tap(User::factory()->create(), function ($user) { $this->postJson(PostWithUserRepository::uriKey(), [ 'title' => 'Create post with owner.', - 'user' => $user->id, + 'user' => $user->getKey(), ])->assertForbidden(); }); } @@ -146,7 +146,7 @@ public function test_unauthorized_via_policy_models_cannot_be_attached() tap(User::factory()->create(), function ($user) { $this->postJson(PostWithUserRepository::uriKey(), [ 'title' => 'Create post with owner.', - 'user' => $user->id, + 'user' => $user->getKey(), ])->assertForbidden(); $this->assertDatabaseCount('posts', 0); @@ -155,7 +155,7 @@ public function test_unauthorized_via_policy_models_cannot_be_attached() $this->postJson(PostWithUserRepository::uriKey(), [ 'title' => 'Create post with owner.', - 'user' => $user->id, + 'user' => $user->getKey(), ])->assertCreated(); }); @@ -169,7 +169,7 @@ public function test_unauthorized_without_authorization_method_defined_to_attach tap(User::factory()->create(), function ($user) { $this->postJson(PostWithUserRepository::uriKey(), [ 'title' => 'Create post with owner.', - 'user' => $user->id, + 'user' => $user->getKey(), ])->assertForbidden(); }); } @@ -214,12 +214,12 @@ public function test_belongs_to_could_choose_columns(): void $post = PostFactory::one(); PostRepository::partialMock() - ->shouldReceive('related') + ->shouldReceive('include') ->andReturn([ 'user' => BelongsTo::make('user', UserRepository::class), ]); - $this->getJson(PostRepository::to($post->id, [ + $this->getJson(PostRepository::route($post->id, [ 'include' => 'user[name]', ]))->assertJson( fn (AssertableJson $json) => $json @@ -234,7 +234,7 @@ class PostWithUserRepository extends Repository { public static $model = Post::class; - public static function related(): array + public static function include(): array { return [ 'user' => BelongsTo::make('user', UserRepository::class), diff --git a/tests/Fields/BelongsToManyFieldTest.php b/tests/Fields/BelongsToManyFieldTest.php index 02ab1d858..6ef63a3d5 100644 --- a/tests/Fields/BelongsToManyFieldTest.php +++ b/tests/Fields/BelongsToManyFieldTest.php @@ -38,7 +38,7 @@ public function test_belongs_to_many_can_hide_relationships_from_show(): void }); CompanyRepository::partialMock() - ->expects('related') + ->shouldReceive('include') ->andReturn([ 'users' => BelongsToMany::make('users', UserRepository::class)->hideFromShow(), ]); @@ -60,7 +60,7 @@ public function test_belongs_to_many_can_hide_relationships_from_index(): void }); CompanyRepository::partialMock() - ->shouldReceive('related') + ->shouldReceive('include') ->andReturn([ 'users' => BelongsToMany::make('users', UserRepository::class)->hideFromIndex(), ]); diff --git a/tests/Fields/FileTest.php b/tests/Fields/FileTest.php index c86eaf54c..038dad4ad 100644 --- a/tests/Fields/FileTest.php +++ b/tests/Fields/FileTest.php @@ -57,7 +57,7 @@ public function test_can_upload_file() $user = $this->mockUsers()->first(); - $this->postJson(UserRepository::uriKey()."/{$user->id}", [ + $this->postJson(UserRepository::uriKey()."/{$user->getKey()}", [ 'avatar' => UploadedFile::fake()->image('image.jpg'), ])->assertOk()->assertJsonFragment([ 'avatar_original' => 'image.jpg', @@ -93,7 +93,7 @@ public function test_can_prune_prunable_files() ->storeAs('avatar.jpg'), ]); - $this->deleteJson(UserRepository::uriKey()."/{$user->id}") + $this->deleteJson(UserRepository::uriKey()."/{$user->getKey()}") ->assertNoContent(); Storage::disk('customDisk')->assertMissing('avatar.jpg'); @@ -116,7 +116,7 @@ public function test_cannot_prune_unpruneable_files() Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg'), ]); - $this->deleteJson(UserRepository::uriKey()."/{$user->id}") + $this->deleteJson(UserRepository::uriKey()."/{$user->getKey()}") ->assertNoContent(); Storage::disk('customDisk')->assertExists('avatar.jpg'); @@ -139,7 +139,7 @@ public function test_deletable_file_could_be_deleted() Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg')->deletable(true), ]); - $this->deleteJson(UserRepository::uriKey()."/{$user->id}/field/avatar") + $this->deleteJson(UserRepository::uriKey()."/{$user->getKey()}/field/avatar") ->assertNoContent(); Storage::disk('customDisk')->assertMissing('avatar.jpg'); @@ -162,7 +162,7 @@ public function test_not_deletable_file_cannot_be_deleted() Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg')->deletable(false), ]); - $this->deleteJson(UserRepository::uriKey()."/{$user->id}/field/avatar") + $this->deleteJson(UserRepository::uriKey()."/{$user->getKey()}/field/avatar") ->assertNotFound(); } @@ -180,7 +180,7 @@ public function test_can_upload_file_using_storable() $user = $this->mockUsers()->first(); - $this->postJson(UserRepository::uriKey()."/{$user->id}", [ + $this->postJson(UserRepository::uriKey()."/{$user->getKey()}", [ 'avatar' => UploadedFile::fake()->image('image.jpg'), ])->assertOk()->assertJsonFragment([ 'avatar' => '/storage/avatar.jpg', @@ -208,7 +208,7 @@ public function test_model_updating_will_replace_file() Image::make('avatar')->disk('customDisk')->storeAs('newAvatar.jpg')->prunable(), ]); - $this->postJson(UserRepository::uriKey()."/{$user->id}", [ + $this->postJson(UserRepository::uriKey()."/{$user->getKey()}", [ 'avatar' => UploadedFile::fake()->image('image.jpg'), ])->assertOk()->assertJsonFragment([ 'avatar' => '/storage/newAvatar.jpg', diff --git a/tests/Fields/HasManyTest.php b/tests/Fields/HasManyTest.php index 7f4a225a0..ce0325f1e 100644 --- a/tests/Fields/HasManyTest.php +++ b/tests/Fields/HasManyTest.php @@ -42,7 +42,7 @@ public function test_has_many_present_on_relations(): void $user = User::factory()->create(); Post::factory()->times(2)->create([ - 'user_id' => $user->id, + 'user_id' => $user->getKey(), ]); $this->getJson(UserWithPosts::uriKey()."/$user->id?related=posts") @@ -67,7 +67,7 @@ public function test_has_many_could_choose_columns(): void Post::factory()->times(2)->create([ 'title' => 'Title', 'description' => 'Description', - 'user_id' => $user->id, + 'user_id' => $user->getKey(), ]); $this->getJson(UserWithPosts::uriKey()."/$user->id?related=posts[title]") @@ -89,10 +89,10 @@ public function test_has_many_could_choose_columns(): void public function test_has_many_paginated_on_relation(): void { $user = tap($this->mockUsers()->first(), function ($user) { - $this->mockPosts($user->id, 22); + $this->mockPosts($user->getKey(), 22); }); - $this->getJson(UserWithPosts::uriKey()."/{$user->id}?related=posts&relatablePerPage=20") + $this->getJson(UserWithPosts::uriKey()."/{$user->getKey()}?related=posts&relatablePerPage=20") ->assertJsonCount(20, 'data.relationships.posts'); } @@ -102,7 +102,7 @@ public function test_has_many_filter_unauthorized_to_see_relationship_posts(): v Gate::policy(Post::class, PostPolicy::class); $user = tap($this->mockUsers()->first(), function ($user) { - $this->mockPosts($user->id, 20); + $this->mockPosts($user->getKey(), 20); }); $this->getJson(UserWithPosts::uriKey()."/$user->id?related=posts") @@ -125,7 +125,7 @@ public function test_field_ignored_when_storing() public function test_can_display_other_pages() { tap($u = $this->mockUsers()->first(), function ($user) { - $this->mockPosts($user->id, 20); + $this->mockPosts($user->getKey(), 20); }); UserWithPosts::partialMock() @@ -145,7 +145,7 @@ public function test_can_display_other_pages() public function test_can_apply_filters(): void { tap($u = $this->mockUsers()->first(), function ($user) { - tap($this->mockPosts($user->id, 20), static function (Collection $posts) { + tap($this->mockPosts($user->getKey(), 20), static function (Collection $posts) { $first = $posts->first(); $first->title = 'wew'; $first->save(); @@ -173,7 +173,7 @@ public function test_filter_unauthorized_posts() Gate::policy(Post::class, PostPolicy::class); tap($u = $this->mockUsers()->first(), function ($user) { - $this->mockPosts($user->id, 5); + $this->mockPosts($user->getKey(), 5); }); UserWithPosts::partialMock() @@ -235,7 +235,7 @@ public function test_can_show(): void HasMany::make('posts', PostRepository::class), ]); - $this->getJson(UserWithPosts::to("$userId/posts/$post->id"), [ + $this->getJson(UserWithPosts::route("$userId/posts/$post->id"), [ 'title' => 'Test', ])->assertJsonStructure([ 'data' => ['attributes'], @@ -325,7 +325,7 @@ class UserWithPosts extends Repository { public static $model = User::class; - public static function related(): array + public static function include(): array { return [ 'posts' => HasMany::make('posts', PostRepository::class), diff --git a/tests/Fields/HasOneFieldTest.php b/tests/Fields/HasOneFieldTest.php index 915ecefcc..f143eee4b 100644 --- a/tests/Fields/HasOneFieldTest.php +++ b/tests/Fields/HasOneFieldTest.php @@ -22,7 +22,7 @@ class HasOneFieldTest extends IntegrationTest protected function setUp(): void { parent::setUp(); - $this->authenticate(); +// $this->authenticate(); unset($_SERVER['restify.post.show']); @@ -39,35 +39,35 @@ protected function tearDown(): void public function test_has_one_present_on_relations(): void { - $post = Post::factory()->create([ - 'user_id' => User::factory(), - ]); + $post = Post::factory()->create(); - $this->getJson(UserWithPostRepository::uriKey()."/$post->id?related=post") - ->assertJsonStructure([ - 'data' => [ - 'relationships' => [ - 'post', - ], + $this->getJson(UserWithPostRepository::route($post->user_id, ['include' => 'post']))->assertJsonStructure([ + 'data' => [ + 'relationships' => [ + 'post', ], - ]); + ], + ]); } public function test_has_one_field_unauthorized_see_relationship(): void { + $this->authenticate(); + $_SERVER['restify.post.show'] = false; Gate::policy(Post::class, PostPolicy::class); tap(Post::factory()->create([ 'user_id' => User::factory(), - ]), function ($post) { - $this->getJson(UserWithPostRepository::uriKey()."/{$post->id}?related=post") - ->assertForbidden(); + ]), function (Post $post) { + $this->postJson(UserWithPostRepository::route($post->user_id, [ + 'include' => 'post', + ]))->assertForbidden(); }); } - public function test_field_ignored_when_storing() + public function test_field_ignored_when_storing(): void { UserWithPostRepository::partialMock() ->shouldReceive('fillFields') @@ -91,45 +91,49 @@ public function test_can_sort_using_has_one_to_field(): void 'post' => HasOne::make('post', PostRepository::class)->sortable('posts.title'), ]; - Post::factory()->create([ + Post::factory()->state([ 'title' => 'Zez', - 'user_id' => User::factory()->create([ - 'name' => 'Last', - ]), - ]); + ])->for(User::factory()->state([ + 'name' => 'Last', + ]))->create(); - Post::factory()->create([ + Post::factory()->state([ 'title' => 'Abc', - 'user_id' => User::factory()->create([ - 'name' => 'First', - ]), - ]); + ])->for(User::factory()->state([ + 'name' => 'First', + ]))->create(); $this - ->getJson(UserRepository::uriKey().'?related=post&sort=-post.attributes.title&perPage=5') - ->assertJson(function (AssertableJson $assertableJson) { - $assertableJson - ->where('data.1.attributes.name', 'First') - ->where('data.0.attributes.name', 'Last') - ->etc(); - }); + ->getJson(UserRepository::route(query: [ + 'include' => 'post', + 'sort' => '-post.attributes.title', + 'perPage' => 5, + ]))->assertJson( + fn (AssertableJson $json) => $json + ->where('data.0.attributes.name', 'Last') + ->where('data.1.attributes.name', 'First') + ->etc() + ); $this - ->getJson(UserRepository::uriKey().'?related=post&sort=post.attributes.title&perPage=5') - ->assertJson(function (AssertableJson $assertableJson) { - $assertableJson - ->where('data.0.attributes.name', 'First') - ->where('data.1.attributes.name', 'Last') - ->etc(); - }); + ->getJson(UserRepository::route(query: [ + 'include' => 'post', + 'sort' => 'post.attributes.title', + 'perPage' => 5, + ]))->assertJson( + fn (AssertableJson $json) => $json + ->where('data.0.attributes.name', 'First') + ->where('data.1.attributes.name', 'Last') + ->etc() + ); } } class UserWithPostRepository extends Repository { - public static $model = User::class; + public static string $model = User::class; - public static function related(): array + public static function include(): array { return [ 'post' => HasOne::make('post', PostRepository::class), diff --git a/tests/Fields/ImageTest.php b/tests/Fields/ImageTest.php index 40d97962a..c5461705a 100644 --- a/tests/Fields/ImageTest.php +++ b/tests/Fields/ImageTest.php @@ -28,7 +28,7 @@ public function test_image_has_default() ); } - public function test_ignore_image_default_value_when_image_exists() + public function test_ignore_image_default_value_when_image_exists(): void { Storage::fake('customDisk'); @@ -40,7 +40,7 @@ public function test_ignore_image_default_value_when_image_exists() $user = $this->mockUsers()->first(); - $this->postJson(UserRepository::uriKey()."/{$user->id}", [ + $this->postJson(UserRepository::uriKey()."/{$user->getKey()}", [ 'avatar' => UploadedFile::fake()->image('image.jpg'), ])->assertOk()->assertJsonFragment([ 'avatar' => '/storage/avatar.jpg', diff --git a/tests/Fields/MorphOneFieldTest.php b/tests/Fields/MorphOneFieldTest.php index cd508050a..c0c1d75ae 100644 --- a/tests/Fields/MorphOneFieldTest.php +++ b/tests/Fields/MorphOneFieldTest.php @@ -65,7 +65,7 @@ class PostWithMophOneRepository extends Repository { public static $model = Post::class; - public static function related(): array + public static function include(): array { return [ 'user' => BelongsTo::make('user', UserRepository::class), diff --git a/tests/Fields/MorphToManyFieldTest.php b/tests/Fields/MorphToManyFieldTest.php index 9f0d905ad..f3762d72c 100644 --- a/tests/Fields/MorphToManyFieldTest.php +++ b/tests/Fields/MorphToManyFieldTest.php @@ -13,6 +13,7 @@ use Binaryk\LaravelRestify\Tests\Fixtures\Role\RoleRepository; use Binaryk\LaravelRestify\Tests\Fixtures\User\User; use Binaryk\LaravelRestify\Tests\IntegrationTest; +use Illuminate\Testing\Fluent\AssertableJson; class MorphToManyFieldTest extends IntegrationTest { @@ -33,23 +34,22 @@ public function test_morph_to_many_displays_in_relationships(): void ); }); - $this->getJson(UserWithRolesRepository::uriKey()."/$user->id?related=roles") - ->assertJsonStructure([ - 'data' => [ - 'relationships' => [ - 'roles' => [], - ], - ], - ])->assertJsonCount(3, 'data.relationships.roles'); + $this->getJson(UserWithRolesRepository::route($user->getKey(), [ + 'related' => 'roles', + ]))->assertJson( + fn (AssertableJson $json) => $json + ->count('data.relationships.roles', 3) + ->etc() + ); } - public function test_morph_to_many_works_with_belongs_to_many() + public function test_morph_to_many_works_with_belongs_to_many(): void { /** * @var User $user */ $user = User::factory()->create(); tap(Company::factory()->create(), function (Company $company) use ($user) { - $company->users()->attach($user->id); + $company->users()->attach($user->getKey()); $user->roles()->attach( Role::factory(3)->create() @@ -85,10 +85,10 @@ class UserWithRolesRepository extends Repository { public static $model = User::class; - public static function related(): array + public static function include(): array { return [ - 'roles' => MorphToMany::make('roles', RoleRepository::class), + 'roles' => MorphToMany::make('roles', RoleRepository::class), 'companies' => BelongsToMany::make('companies', CompanyRepository::class), ]; } diff --git a/tests/Fixtures/Company/CompanyRepository.php b/tests/Fixtures/Company/CompanyRepository.php index ed2bc9856..5c0371aa4 100644 --- a/tests/Fixtures/Company/CompanyRepository.php +++ b/tests/Fixtures/Company/CompanyRepository.php @@ -12,7 +12,7 @@ class CompanyRepository extends Repository { public static $model = Company::class; - public static function related(): array + public static function include(): array { return [ 'users' => BelongsToMany::make('users', UserRepository::class)->withPivot( diff --git a/tests/Fixtures/Post/PostPolicy.php b/tests/Fixtures/Post/PostPolicy.php index 817c6ad18..b94d70111 100644 --- a/tests/Fixtures/Post/PostPolicy.php +++ b/tests/Fixtures/Post/PostPolicy.php @@ -2,41 +2,24 @@ namespace Binaryk\LaravelRestify\Tests\Fixtures\Post; -use Binaryk\LaravelRestify\Tests\Fixtures\User\User; - class PostPolicy { - /** - * Determine if the given user can use repository. - * - * @param User|null $user - * @return bool|mixed - */ - public function allowRestify($user = null) + public function allowRestify() { return $_SERVER['restify.post.allowRestify'] ?? true; } - /** - * Determine if post can be show. - */ - public function show($user = null) + public function show(): bool { return $_SERVER['restify.post.show'] ?? true; } - /** - * Determine if posts can be created. - */ - public function store($user = null) + public function store(): bool { return $_SERVER['restify.post.store'] ?? true; } - /** - * Determine if posts can be stored bulk. - */ - public function storeBulk($user) + public function storeBulk($user): bool { return $_SERVER['restify.post.storeBulk'] ?? true; } diff --git a/tests/Fixtures/User/DisableProfileAction.php b/tests/Fixtures/User/DisableProfileAction.php index 9d9399319..95b0344bd 100644 --- a/tests/Fixtures/User/DisableProfileAction.php +++ b/tests/Fixtures/User/DisableProfileAction.php @@ -12,10 +12,10 @@ class DisableProfileAction extends Action public static $uriKey = 'disable_profile'; - public function handle(ActionRequest $request, $foo = 'foo'): JsonResponse + public function handle(ActionRequest $request, string $foo = 'foo'): JsonResponse { static::$applied[] = $foo; - return data(['succes' => 'true']); + return data(['success' => 'true']); } } diff --git a/tests/Fixtures/User/MockUser.php b/tests/Fixtures/User/MockUser.php new file mode 100644 index 000000000..5b26ca16c --- /dev/null +++ b/tests/Fixtures/User/MockUser.php @@ -0,0 +1,8 @@ + 'datetime', ]; - public function getEmail() + public function getEmail(): string { return $this->email; } - public function createToken($name, array $scopes = []) + public function createToken($name, array $scopes = []): object { return new class { public $accessToken = 'token'; diff --git a/tests/Fixtures/User/UserRepository.php b/tests/Fixtures/User/UserRepository.php index 37616ee47..9ea8f91f7 100644 --- a/tests/Fixtures/User/UserRepository.php +++ b/tests/Fixtures/User/UserRepository.php @@ -3,7 +3,6 @@ namespace Binaryk\LaravelRestify\Tests\Fixtures\User; use Binaryk\LaravelRestify\Contracts\RestifySearchable; -use Binaryk\LaravelRestify\Fields\Field; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Repositories\UserProfile; @@ -12,9 +11,9 @@ class UserRepository extends Repository { use UserProfile; - public static $model = User::class; + public static string $model = User::class; - public static $wasBooted = false; + public static bool $wasBooted = false; public static array $search = [ 'id', @@ -34,11 +33,11 @@ class UserRepository extends Repository public function fields(RestifyRequest $request): array { return [ - Field::new('name')->rules('sometimes', 'nullable', 'min:4'), + field('name')->rules('sometimes', 'nullable', 'min:4'), - Field::new('email')->rules('required', 'unique:users'), + field('email')->rules('required', 'unique:users'), - Field::new('password'), + field('password'), ]; } @@ -51,7 +50,7 @@ public function actions(RestifyRequest $request): array ]; } - protected static function booted() + protected static function booted(): void { static::$wasBooted = true; } diff --git a/tests/Getters/ListGettersControllerTest.php b/tests/Getters/ListGettersControllerTest.php index 1933e4c5e..16fa51d7a 100644 --- a/tests/Getters/ListGettersControllerTest.php +++ b/tests/Getters/ListGettersControllerTest.php @@ -10,7 +10,7 @@ class ListGettersControllerTest extends IntegrationTest { public function test_could_list_getters_for_repository(): void { - $this->getJson(PostRepository::to('getters')) + $this->getJson(PostRepository::route('getters')) ->assertOk() ->assertJson( fn (AssertableJson $json) => $json @@ -25,7 +25,7 @@ public function test_could_list_getters_for_given_repository(): void { $this->mockPosts(1, 2); - $this->getJson(PostRepository::to('1/getters')) + $this->getJson(PostRepository::route('1/getters')) ->assertOk() ->assertJson( fn (AssertableJson $json) => $json diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 5bda429e4..57830b1f3 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -9,7 +9,9 @@ use Binaryk\LaravelRestify\Tests\Fixtures\Company\CompanyRepository; use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; +use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostWithHiddenFieldRepository; use Binaryk\LaravelRestify\Tests\Fixtures\Role\RoleRepository; +use Binaryk\LaravelRestify\Tests\Fixtures\User\MockUser; use Binaryk\LaravelRestify\Tests\Fixtures\User\User; use Binaryk\LaravelRestify\Tests\Fixtures\User\UserRepository; use Illuminate\Contracts\Auth\Authenticatable; @@ -89,16 +91,16 @@ public function loadRepositories(): self UserRepository::class, PostRepository::class, CompanyRepository::class, - \Binaryk\LaravelRestify\Tests\Fixtures\Post\PostWithHiddenFieldRepository::class, + PostWithHiddenFieldRepository::class, RoleRepository::class, ]); return $this; } - protected function authenticate(Authenticatable $user = null) + protected function authenticate(Authenticatable $user = null): self { - $this->actingAs($this->authenticatedAs = $user ?? Mockery::mock(Authenticatable::class)); + $this->actingAs($this->authenticatedAs = $user ?? Mockery::mock(MockUser::class)); if (is_null($user)) { $this->authenticatedAs->shouldReceive('getAuthIdentifier')->andReturn(1); diff --git a/tests/Migrations/2017_10_10_000000_create_users_table.php b/tests/Migrations/2017_10_10_000000_create_users_table.php index 0630c4b3a..847d905f3 100644 --- a/tests/Migrations/2017_10_10_000000_create_users_table.php +++ b/tests/Migrations/2017_10_10_000000_create_users_table.php @@ -21,6 +21,7 @@ public function up() $table->float('avatar_size')->nullable(); $table->string('email')->unique(); $table->string('password'); + $table->boolean('active')->default(true); $table->timestamp('email_verified_at')->nullable(); $table->rememberToken(); $table->timestamps(); diff --git a/tests/Repositories/RepositoryCustomPrefixTest.php b/tests/Repositories/RepositoryCustomPrefixTest.php index a30c6f4cb..63c964d88 100644 --- a/tests/Repositories/RepositoryCustomPrefixTest.php +++ b/tests/Repositories/RepositoryCustomPrefixTest.php @@ -24,13 +24,13 @@ protected function tearDown(): void PostRepository::$indexPrefix = null; } - public function test_repository_can_have_custom_prefix() + public function test_repository_can_have_custom_prefix(): void { $this->getJson('api/index/'.PostRepository::uriKey()) ->assertSuccessful(); } - public function test_repository_prefix_block_default_route() + public function test_repository_prefix_block_default_route(): void { $this->getJson(PostRepository::uriKey()) ->assertForbidden(); diff --git a/tests/Repositories/RepositoryEventsTest.php b/tests/Repositories/RepositoryEventsTest.php index b47863af9..42119b76b 100644 --- a/tests/Repositories/RepositoryEventsTest.php +++ b/tests/Repositories/RepositoryEventsTest.php @@ -3,6 +3,7 @@ namespace Binaryk\LaravelRestify\Tests\Repositories; use Binaryk\LaravelRestify\Repositories\Repository; +use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; use Binaryk\LaravelRestify\Tests\Fixtures\User\UserRepository; use Binaryk\LaravelRestify\Tests\IntegrationTest; @@ -17,20 +18,20 @@ protected function setUp(): void Repository::clearBootedRepositories(); } - public function test_booted_method_not_invoked_when_foreign_repository() + public function test_booted_method_not_invoked_when_foreign_repository(): void { UserRepository::$wasBooted = false; - $this->getJson('posts'); + $this->getJson(PostRepository::route()); $this->assertFalse(UserRepository::$wasBooted); } - public function test_booted_method_invoked() + public function test_booted_method_invoked(): void { UserRepository::$wasBooted = false; - $this->getJson('users'); + $this->getJson(UserRepository::uriKey()); $this->assertTrue(UserRepository::$wasBooted); } diff --git a/tests/Unit/MatchableFilterTest.php b/tests/Unit/MatchableFilterTest.php deleted file mode 100644 index c40a10c6b..000000000 --- a/tests/Unit/MatchableFilterTest.php +++ /dev/null @@ -1,30 +0,0 @@ -jsonSerialize()), - function (AssertableJson $json) { - $json - ->dump() - ->where('key', 'matches') - ->where('title', 'Approved At') - ->where('column', 'approved_at') - ->etc() - ; - } - ); - } -} diff --git a/tests/Unit/RepositoryWithRoutesTest.php b/tests/Unit/RepositoryWithRoutesTest.php index 6aa32c7da..af4651a49 100644 --- a/tests/Unit/RepositoryWithRoutesTest.php +++ b/tests/Unit/RepositoryWithRoutesTest.php @@ -26,7 +26,7 @@ protected function setUp(): void public function test_can_add_custom_routes(): void { - $this->getJson(RepositoryWithRoutes::to('main-testing')) + $this->getJson(RepositoryWithRoutes::route('main-testing')) ->assertOk() ->assertJson([ 'success' => true, From 2016c2ebc806c2fbd2246f83051768f3231704a0 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Mon, 4 Jul 2022 18:02:54 +0300 Subject: [PATCH 03/42] fix: route method --- tests/Controllers/RepositoryDestroyBulkControllerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Controllers/RepositoryDestroyBulkControllerTest.php b/tests/Controllers/RepositoryDestroyBulkControllerTest.php index 019d8aee6..a9ea686cd 100644 --- a/tests/Controllers/RepositoryDestroyBulkControllerTest.php +++ b/tests/Controllers/RepositoryDestroyBulkControllerTest.php @@ -27,7 +27,7 @@ public function test_basic_bulk_delete_works(): void $this->withoutExceptionHandling(); - $this->deleteJson(PostRepository::to('bulk/delete'), [ + $this->deleteJson(PostRepository::route('bulk/delete'), [ $post1->getKey(), $post2->getKey(), ])->assertOk(); From 907b5e503909815856571ce9193c80cc0630081e Mon Sep 17 00:00:00 2001 From: binaryk Date: Mon, 4 Jul 2022 15:05:59 +0000 Subject: [PATCH 04/42] Fix styling --- tests/Feature/Filters/MatchFilterTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/Filters/MatchFilterTest.php b/tests/Feature/Filters/MatchFilterTest.php index cbacba5ef..79ed0b94d 100644 --- a/tests/Feature/Filters/MatchFilterTest.php +++ b/tests/Feature/Filters/MatchFilterTest.php @@ -16,7 +16,7 @@ class MatchFilterTest extends IntegrationTest { public function test_matchable_filter_has_key(): void { - $filter = new class extends MatchFilter { + $filter = new class () extends MatchFilter { public ?string $column = 'approved_at'; }; From a9bdddd258aa9e12230c8c8a285dbb75b8a32a58 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Mon, 4 Jul 2022 18:27:04 +0300 Subject: [PATCH 05/42] fix: roadmap --- ROADMAP.md | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 04fb1291a..d0bb433df 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,12 +1,33 @@ ## Roadmap -- Improve controllers -- Reduce the main Repository class by using traits -- Request validations should be rewritten -- Revisit the InteractWithRepositories trait and clean model queries accordingly -- Adding support for PHPStan and configure the level 4 -- Make sure any action is permitted unless the Model Policy exists -- Add PestPHP support -- Clean up all tests using AssertableJson -- Adding support for custom ActionLogs (ie ActionLog::register("project marked active by user Auth::id()", $project->id)) -- Adding a command that lists all Restify registered routes `php artisan restify:routes` +7.x + +### Fixes + +- [ ] Clean up controllers +- [ ] Reduce the main Repository class by using traits +- [ ] Request validations should be rewritten +- [ ] Revisit the `InteractWithRepositories` trait and clean model queries accordingly +- [ ] Adding support for PHPStan and configure the level 4 +- [ ] Clean up all tests using AssertableJson [x] + +### Features + +- [ ] Adding support for custom ActionLogs (ie ActionLog::register("project marked active by user Auth::id()", $project->id)) +- [ ] Ensure `$with` loads relationship in `show` requests +- [ ] Make sure any action isn't permitted unless the Model Policy exists +- [ ] Ability to make an endpoint public using a policy method + +8.x + +### Fixes + +- [ ] Adding Larastan support +- [ ] Drop Psalm +- [ ] Adding PestPHP support + +### Features + +- [ ] Serialize nested relationships +- [ ] Having a helper method that allow to return data using the repository from a custom controller `PostRepository::withModels(Post::query()->take(5)->get())->include('user')->serializeForShow()` +- [ ] Adding a command that lists all Restify registered routes `php artisan restify:routes` From e9d49c6ac0afee593b034271bd4962f7a4874acd Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Mon, 4 Jul 2022 18:57:52 +0300 Subject: [PATCH 06/42] fix: Make sure any action isn't permitted unless the Model Policy exists --- UPGRADING.md | 1 + src/Traits/AuthorizableModels.php | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index ed0b2ecee..5ea00bbbc 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -5,6 +5,7 @@ - PHP8.1 is required - Laravel 9 is required - Restify.php - `repositoryForKey` renamed to `repositoryClassForKey` +- Any action permitted unless the Model Policy exists and the method is defined - Repository.php: - static `to` method renamed to `route` - `related` static method deleted, replace with `include` diff --git a/src/Traits/AuthorizableModels.php b/src/Traits/AuthorizableModels.php index 4bafedb4e..f9a8b2920 100644 --- a/src/Traits/AuthorizableModels.php +++ b/src/Traits/AuthorizableModels.php @@ -53,7 +53,7 @@ public function authorizeToUseRepository(Request $request) public static function authorizedToUseRepository(Request $request): bool { if (! static::authorizable()) { - return true; + return false; } return method_exists(Gate::getPolicyFor(static::newModel()), 'allowRestify') @@ -117,7 +117,7 @@ public static function authorizedToStore(Request $request) return Gate::check('store', static::guessModelClassName()); } - return true; + return false; } public static function authorizedToStoreBulk(Request $request) @@ -126,7 +126,7 @@ public static function authorizedToStoreBulk(Request $request) return Gate::check('storeBulk', static::guessModelClassName()); } - return true; + return false; } /** @@ -145,7 +145,7 @@ public function authorizeToUpdate(Request $request) public function authorizeToAttach(Request $request, $method, $model) { if (! static::authorizable()) { - return true; + return false; } $policyClass = get_class(Gate::getPolicyFor($this->model())); @@ -158,18 +158,18 @@ public function authorizeToAttach(Request $request, $method, $model) abort(403, 'You cannot attach model:'.get_class($model).', to the model:'.get_class($this->model()).', check your permissions.'); } - return true; + return false; } public function authorizeToDetach(Request $request, $method, $model) { if (! static::authorizable()) { - return true; + return false; } $authorized = method_exists(Gate::getPolicyFor($this->model()), $method) ? Gate::check($method, [$this->model(), $model]) - : true; + : false; if (false === $authorized) { throw new AuthorizationException(); @@ -246,7 +246,7 @@ public function authorizeTo(Request $request, $ability) */ public function authorizedTo(Request $request, $ability) { - return static::authorizable() ? Gate::check($ability, $this->resource) : true; + return static::authorizable() ? Gate::check($ability, $this->resource) : false; } /** From 72e07eb378cde9683a74b29f42d61093493188de Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Wed, 6 Jul 2022 15:32:40 +0300 Subject: [PATCH 07/42] fix: wip --- src/Traits/AuthorizableModels.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Traits/AuthorizableModels.php b/src/Traits/AuthorizableModels.php index f9a8b2920..51e378dfe 100644 --- a/src/Traits/AuthorizableModels.php +++ b/src/Traits/AuthorizableModels.php @@ -58,7 +58,7 @@ public static function authorizedToUseRepository(Request $request): bool return method_exists(Gate::getPolicyFor(static::newModel()), 'allowRestify') ? Gate::check('allowRestify', get_class(static::newModel())) - : false; + :true; } /** @@ -169,7 +169,7 @@ public function authorizeToDetach(Request $request, $method, $model) $authorized = method_exists(Gate::getPolicyFor($this->model()), $method) ? Gate::check($method, [$this->model(), $model]) - : false; + :true; if (false === $authorized) { throw new AuthorizationException(); @@ -246,7 +246,7 @@ public function authorizeTo(Request $request, $ability) */ public function authorizedTo(Request $request, $ability) { - return static::authorizable() ? Gate::check($ability, $this->resource) : false; + return static::authorizable() ? Gate::check($ability, $this->resource) :true; } /** From 45b3f0de01f3ce75835350cb252fd70778b3a552 Mon Sep 17 00:00:00 2001 From: binaryk Date: Wed, 6 Jul 2022 12:33:03 +0000 Subject: [PATCH 08/42] Fix styling --- src/Traits/AuthorizableModels.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Traits/AuthorizableModels.php b/src/Traits/AuthorizableModels.php index 51e378dfe..1b2a446d7 100644 --- a/src/Traits/AuthorizableModels.php +++ b/src/Traits/AuthorizableModels.php @@ -58,7 +58,7 @@ public static function authorizedToUseRepository(Request $request): bool return method_exists(Gate::getPolicyFor(static::newModel()), 'allowRestify') ? Gate::check('allowRestify', get_class(static::newModel())) - :true; + : true; } /** @@ -169,7 +169,7 @@ public function authorizeToDetach(Request $request, $method, $model) $authorized = method_exists(Gate::getPolicyFor($this->model()), $method) ? Gate::check($method, [$this->model(), $model]) - :true; + : true; if (false === $authorized) { throw new AuthorizationException(); @@ -246,7 +246,7 @@ public function authorizeTo(Request $request, $ability) */ public function authorizedTo(Request $request, $ability) { - return static::authorizable() ? Gate::check($ability, $this->resource) :true; + return static::authorizable() ? Gate::check($ability, $this->resource) : true; } /** From 5654fc3bd215727f70a5ccc2e94d40e8f765f33d Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Wed, 6 Jul 2022 15:40:39 +0300 Subject: [PATCH 09/42] fix: Block requests without policy (#466) * fix: Block requests without policy * Fix styling * fix: wip * fix: wip * fix: wip * fix: tests * fix: config Co-authored-by: binaryk --- composer.json | 3 +- src/Models/ActionLogPolicy.php | 36 ++++++++++ .../RepositoryDestroyControllerTest.php | 5 ++ .../RepositoryStoreBulkControllerTest.php | 3 + .../RepositoryStoreControllerTest.php | 2 + .../RepositoryUpdateControllerTest.php | 2 + tests/Fixtures/Post/PostPolicy.php | 5 ++ tests/Fixtures/Role/RolePolicy.php | 36 ++++++++++ tests/Getters/PerformGetterControllerTest.php | 10 ++- tests/IntegrationTest.php | 66 ++++++++++++++----- .../RepositoryCustomPrefixTest.php | 3 +- 11 files changed, 151 insertions(+), 20 deletions(-) create mode 100644 src/Models/ActionLogPolicy.php create mode 100644 tests/Fixtures/Role/RolePolicy.php diff --git a/composer.json b/composer.json index 796be8ecf..058e04abc 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,8 @@ "test-coverage": "./vendor/bin/phpunit --coverage-html coverage" }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": true }, "extra": { "laravel": { diff --git a/src/Models/ActionLogPolicy.php b/src/Models/ActionLogPolicy.php new file mode 100644 index 000000000..3ae4666b2 --- /dev/null +++ b/src/Models/ActionLogPolicy.php @@ -0,0 +1,36 @@ +authenticate(); } @@ -23,6 +24,8 @@ public function test_destroy_works(): void $this->assertInstanceOf(Post::class, Post::find($post->id)); + $_SERVER['restify.post.delete'] = true; + $this->deleteJson('posts/'.$post->id, [ 'title' => 'Updated title', ]) @@ -52,6 +55,8 @@ public function test_destroying_repository_log_action(): void 'title' => 'Original title', ]); + $_SERVER['restify.post.delete'] = true; + $this->deleteJson("posts/$post->id")->assertNoContent(); $this->assertDatabaseHas('action_logs', [ diff --git a/tests/Controllers/RepositoryStoreBulkControllerTest.php b/tests/Controllers/RepositoryStoreBulkControllerTest.php index f8d59ff86..3a8c842b9 100644 --- a/tests/Controllers/RepositoryStoreBulkControllerTest.php +++ b/tests/Controllers/RepositoryStoreBulkControllerTest.php @@ -42,6 +42,9 @@ public function test_unauthorized_store_bulk(): void public function test_user_can_bulk_create_posts(): void { $user = $this->mockUsers()->first(); + + $_SERVER['restify.post.storeBulk'] = true; + $this->postJson('posts/bulk', [ [ 'user_id' => $user->getKey(), diff --git a/tests/Controllers/RepositoryStoreControllerTest.php b/tests/Controllers/RepositoryStoreControllerTest.php index 0298c17c1..2032e0b86 100644 --- a/tests/Controllers/RepositoryStoreControllerTest.php +++ b/tests/Controllers/RepositoryStoreControllerTest.php @@ -39,6 +39,8 @@ public function test_unauthorized_store(): void public function test_success_storing(): void { + $_SERVER['restify.post.store'] = true; + $this->postJson('posts', $data = [ 'user_id' => ($user = $this->mockUsers()->first())->id, 'title' => $title = 'Some post title', diff --git a/tests/Controllers/RepositoryUpdateControllerTest.php b/tests/Controllers/RepositoryUpdateControllerTest.php index 15bafd641..15abc0f3a 100644 --- a/tests/Controllers/RepositoryUpdateControllerTest.php +++ b/tests/Controllers/RepositoryUpdateControllerTest.php @@ -65,6 +65,8 @@ public function test_cannot_update_unauthorized_fields(): void Field::new('title'), ]); + $_SERVER['restify.post.update'] = true; + $this->putJson(PostRepository::route(Post::factory()->create([ 'image' => null, 'title' => 'Initial', diff --git a/tests/Fixtures/Post/PostPolicy.php b/tests/Fixtures/Post/PostPolicy.php index b94d70111..fa0091bd8 100644 --- a/tests/Fixtures/Post/PostPolicy.php +++ b/tests/Fixtures/Post/PostPolicy.php @@ -29,6 +29,11 @@ public function update($user, $post) return $_SERVER['restify.post.update'] ?? true; } + public function updateBulk($user): bool + { + return $_SERVER['restify.post.updateBulk'] ?? true; + } + public function deleteBulk($user, $post) { return $_SERVER['restify.post.deleteBulk'] ?? true; diff --git a/tests/Fixtures/Role/RolePolicy.php b/tests/Fixtures/Role/RolePolicy.php new file mode 100644 index 000000000..614d04a81 --- /dev/null +++ b/tests/Fixtures/Role/RolePolicy.php @@ -0,0 +1,36 @@ +ensureLoggedIn(); + } + public function test_could_perform_getter(): void { - $this->getJson(PostRepository::getter(PostsIndexGetter::class)) + $this + ->getJson(PostRepository::getter(PostsIndexGetter::class)) ->assertOk() ->assertJson( fn (AssertableJson $json) => $json diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 270fb40bb..d799c90bd 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -3,33 +3,43 @@ namespace Binaryk\LaravelRestify\Tests; use Binaryk\LaravelRestify\LaravelRestifyServiceProvider; +use Binaryk\LaravelRestify\Models\ActionLog; +use Binaryk\LaravelRestify\Models\ActionLogPolicy; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\RestifyApplicationServiceProvider; +use Binaryk\LaravelRestify\Tests\Fixtures\Company\Company; +use Binaryk\LaravelRestify\Tests\Fixtures\Company\CompanyPolicy; use Binaryk\LaravelRestify\Tests\Fixtures\Company\CompanyRepository; use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; +use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostPolicy; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostWithHiddenFieldRepository; +use Binaryk\LaravelRestify\Tests\Fixtures\Role\Role; +use Binaryk\LaravelRestify\Tests\Fixtures\Role\RolePolicy; use Binaryk\LaravelRestify\Tests\Fixtures\Role\RoleRepository; use Binaryk\LaravelRestify\Tests\Fixtures\User\MockUser; use Binaryk\LaravelRestify\Tests\Fixtures\User\User; +use Binaryk\LaravelRestify\Tests\Fixtures\User\UserPolicy; use Binaryk\LaravelRestify\Tests\Fixtures\User\UserRepository; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; use JetBrains\PhpStorm\Pure; use Mockery; use Orchestra\Testbench\TestCase; abstract class IntegrationTest extends TestCase { - protected Mockery\MockInterface | User $authenticatedAs; + protected Mockery\MockInterface|User|null $authenticatedAs = null; protected function setUp(): void { parent::setUp(); $this->loadRepositories() + ->policies() ->loadMigrations(); $this->app['config']->set('config.auth.user_model', User::class); @@ -37,12 +47,14 @@ protected function setUp(): void $this->app->register(RestifyApplicationServiceProvider::class); Factory::guessFactoryNamesUsing( - fn (string $modelName) => 'Binaryk\\LaravelRestify\\Tests\\Factories\\' . class_basename($modelName) . 'Factory' + fn (string $modelName) => 'Binaryk\\LaravelRestify\\Tests\\Factories\\'.class_basename($modelName).'Factory' ); Restify::$authUsing = static function () { return true; }; + + $this->ensureLoggedIn(); } protected function tearDown(): void @@ -71,7 +83,7 @@ protected function getEnvironmentSetUp($app): void 'prefix' => '', ]); - include_once __DIR__ . '/../database/migrations/create_action_logs_table.php.stub'; + include_once __DIR__.'/../database/migrations/create_action_logs_table.php.stub'; (new \CreateActionLogsTable())->up(); } @@ -79,7 +91,7 @@ protected function loadMigrations(): self { $this->loadMigrationsFrom([ '--database' => 'sqlite', - '--path' => realpath(__DIR__ . DIRECTORY_SEPARATOR . 'Migrations'), + '--path' => realpath(__DIR__.DIRECTORY_SEPARATOR.'Migrations'), ]); return $this; @@ -133,24 +145,44 @@ protected function mockPost(array $attributes = []): Post public function getTempDirectory($suffix = ''): string { - return __DIR__ . '/TestSupport/temp' . ($suffix === '' ? '' : '/' . $suffix); + return __DIR__.'/TestSupport/temp'.($suffix === '' ? '' : '/'.$suffix); } #[Pure] - public function getMediaDirectory($suffix = ''): string - { - return $this->getTempDirectory() . '/media' . ($suffix === '' ? '' : '/' . $suffix); - } + public function getMediaDirectory($suffix = ''): string + { + return $this->getTempDirectory().'/media'.($suffix === '' ? '' : '/'.$suffix); + } #[Pure] - public function getTestFilesDirectory($suffix = ''): string - { - return $this->getTempDirectory() . '/testfiles' . ($suffix === '' ? '' : '/' . $suffix); - } + public function getTestFilesDirectory($suffix = ''): string + { + return $this->getTempDirectory().'/testfiles'.($suffix === '' ? '' : '/'.$suffix); + } #[Pure] - public function getTestJpg(): string - { - return $this->getTestFilesDirectory('test.jpg'); - } + public function getTestJpg(): string + { + return $this->getTestFilesDirectory('test.jpg'); + } + + private function policies(): self + { + Gate::policy(Post::class, PostPolicy::class); + Gate::policy(User::class, UserPolicy::class); + Gate::policy(Company::class, CompanyPolicy::class); + Gate::policy(Role::class, RolePolicy::class); + Gate::policy(ActionLog::class, ActionLogPolicy::class); + + return $this; + } + + protected function ensureLoggedIn(): self + { + if (is_null($this->authenticatedAs)) { + $this->authenticate(); + } + + return $this; + } } diff --git a/tests/Repositories/RepositoryCustomPrefixTest.php b/tests/Repositories/RepositoryCustomPrefixTest.php index 63c964d88..f572762cf 100644 --- a/tests/Repositories/RepositoryCustomPrefixTest.php +++ b/tests/Repositories/RepositoryCustomPrefixTest.php @@ -26,7 +26,8 @@ protected function tearDown(): void public function test_repository_can_have_custom_prefix(): void { - $this->getJson('api/index/'.PostRepository::uriKey()) + $this + ->getJson('api/index/'.PostRepository::uriKey()) ->assertSuccessful(); } From 38ea8e8f92fcdec015258596e34bf68e8eca84ae Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Wed, 6 Jul 2022 16:47:55 +0300 Subject: [PATCH 10/42] fix: Adding package tools and fixing related bug (#467) --- ROADMAP.md | 1 + composer.json | 3 +- config/{config.php => restify.php} | 0 src/Eager/RelatedCollection.php | 5 +- src/Generators/DatabaseGenerator.php | 2 +- src/LaravelRestifyServiceProvider.php | 79 +++++++++++---------------- 6 files changed, 40 insertions(+), 50 deletions(-) rename config/{config.php => restify.php} (100%) diff --git a/ROADMAP.md b/ROADMAP.md index d0bb433df..16ae0ed0e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,6 +10,7 @@ - [ ] Revisit the `InteractWithRepositories` trait and clean model queries accordingly - [ ] Adding support for PHPStan and configure the level 4 - [ ] Clean up all tests using AssertableJson [x] +- [x] Make sure the `include` matches array key firstly, and secondly the relationship name ### Features diff --git a/composer.json b/composer.json index 058e04abc..a4dac8105 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ "php": "^8.1", "illuminate/contracts": "^9.0", "spatie/data-transfer-object": "^3.1", - "spatie/once": "^3.0" + "spatie/once": "^3.0", + "spatie/laravel-package-tools": "^1.12" }, "require-dev": { "brianium/paratest": "^6.2", diff --git a/config/config.php b/config/restify.php similarity index 100% rename from config/config.php rename to config/restify.php diff --git a/src/Eager/RelatedCollection.php b/src/Eager/RelatedCollection.php index 617cfa8e4..148ec37d2 100644 --- a/src/Eager/RelatedCollection.php +++ b/src/Eager/RelatedCollection.php @@ -23,7 +23,7 @@ public function intoAssoc(): self $mapKey = is_numeric($key) ? $value : $key; if ($value instanceof EagerField) { - $mapKey = $value->getAttribute(); + $mapKey = $key ?: $value->getAttribute(); } return [ @@ -120,7 +120,8 @@ function (Related $related) use ($value) { public function authorized(RestifyRequest $request) { - return $this->intoAssoc() + return $this + ->intoAssoc() ->filter(fn ($key, $value) => $key instanceof EagerField ? $key->authorize($request) : true); } diff --git a/src/Generators/DatabaseGenerator.php b/src/Generators/DatabaseGenerator.php index ad9c50b01..778671aee 100644 --- a/src/Generators/DatabaseGenerator.php +++ b/src/Generators/DatabaseGenerator.php @@ -75,7 +75,7 @@ public function integer(Column $columnDefinition, $column): ?int $guessTable = Str::pluralStudly(Str::beforeLast($column, '_id')); if (Schema::hasTable($guessTable)) { - return optional(DB::table($guessTable)->inRandomOrder()->first())->getKey() ?? $this->faker->randomNumber(4); + return optional(DB::table($guessTable)->inRandomOrder()->first())->id ?? $this->faker->randomNumber(4); } } diff --git a/src/LaravelRestifyServiceProvider.php b/src/LaravelRestifyServiceProvider.php index 875d510d6..afc9a5d83 100644 --- a/src/LaravelRestifyServiceProvider.php +++ b/src/LaravelRestifyServiceProvider.php @@ -16,16 +16,27 @@ use Binaryk\LaravelRestify\Commands\StubCommand; use Binaryk\LaravelRestify\Http\Middleware\RestifyInjector; use Binaryk\LaravelRestify\Repositories\Repository; -use Illuminate\Contracts\Http\Kernel as HttpKernel; -use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Str; +use Illuminate\Contracts\Container\BindingResolutionException; +use Illuminate\Contracts\Http\Kernel; +use Spatie\LaravelPackageTools\Package; +use Spatie\LaravelPackageTools\PackageServiceProvider; -class LaravelRestifyServiceProvider extends ServiceProvider +class LaravelRestifyServiceProvider extends PackageServiceProvider { - public function boot(): void + public function configurePackage(Package $package): void { - if ($this->app->runningInConsole()) { - $this->commands([ + $package + ->name('laravel-restify') + ->hasConfigFile() + ->hasMigration('create_action_logs_table') + ->runsMigrations() + ->hasCommands([ + RepositoryCommand::class, + ActionCommand::class, + GetterCommand::class, + StoreCommand::class, + FilterCommand::class, + DevCommand::class, SetupCommand::class, PolicyCommand::class, BaseRepositoryCommand::class, @@ -33,13 +44,26 @@ public function boot(): void StubCommand::class, PublishAuthCommand::class, ]); + } + + /** + * @throws BindingResolutionException + */ + public function packageBooted(): void + { + if ($this->app->runningInConsole()) { $this->registerPublishing(); } - $this->app->make(HttpKernel::class)->pushMiddleware(RestifyInjector::class); + /** + * @var Kernel $kernel + */ + $kernel = $this->app->make(Kernel::class); + + $kernel->pushMiddleware(RestifyInjector::class); } - public function register(): void + public function packageRegistered(): void { Repository::clearBootedRepositories(); @@ -47,15 +71,6 @@ public function register(): void $this->app->singleton('laravel-restify', function () { return new Restify(); }); - - $this->commands([ - RepositoryCommand::class, - ActionCommand::class, - GetterCommand::class, - StoreCommand::class, - FilterCommand::class, - DevCommand::class, - ]); } protected function registerPublishing(): void @@ -63,33 +78,5 @@ protected function registerPublishing(): void $this->publishes([ __DIR__.'/Commands/stubs/RestifyServiceProvider.stub' => app_path('Providers/RestifyServiceProvider.php'), ], 'restify-provider'); - - $this->publishes([ - __DIR__.'/../config/config.php' => config_path('restify.php'), - ], 'restify-config'); - - $migrationFileName = 'create_action_logs_table.php.stub'; - if (! $this->migrationFileExists($migrationFileName)) { - $this->publishes([ - __DIR__."/../database/migrations/{$migrationFileName}" => database_path('migrations/'.date( - 'Y_m_d_His', - time() - ).'_'.Str::before($migrationFileName, '.stub')), - ], 'restify-migrations'); - } - - $this->mergeConfigFrom(__DIR__.'/../config/config.php', 'restify'); - } - - public static function migrationFileExists(string $migrationFileName): bool - { - $len = strlen($migrationFileName); - foreach (glob(database_path('migrations/*.php')) as $filename) { - if ((substr($filename, -$len) === $migrationFileName)) { - return true; - } - } - - return false; } } From 9b1d726a83cac98d2cf0f98ce0d23a8ec2c40cfb Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Thu, 7 Jul 2022 12:49:29 +0300 Subject: [PATCH 11/42] Tests 2 (#468) * fix: use route helper * Fix styling * fix: tests refactoring * Fix styling * fix: route key * fix: wip * Fix styling * fix: phpunit config * fix: coverage * fix: factories * Fix styling * fix: wip * fix: wip * Fix styling Co-authored-by: binaryk --- composer.json | 2 +- .../create_action_logs_table.php.stub | 4 +- phpunit.xml | 36 ---- phpunit.xml.dist | 18 +- src/Repositories/Concerns/Testing.php | 11 +- src/Restify.php | 10 +- tests/Actions/FieldActionTest.php | 8 +- tests/Actions/ListActionsControllerTest.php | 12 +- tests/Actions/PerformActionControllerTest.php | 13 +- tests/Concerns/Mockers.php | 31 ++++ .../GlobalSearchControllerTest.php | 38 ++-- .../Index/NestedRepositoryControllerTest.php | 46 ++--- .../Index/RepositoryIndexControllerTest.php | 2 +- tests/Controllers/ProfileControllerTest.php | 33 ++-- .../RepositoryAttachControllerTest.php | 52 +++--- .../RepositoryDestroyControllerTest.php | 4 +- .../RepositoryDetachControllerTest.php | 10 +- .../RepositoryFilterControllerTest.php | 16 +- .../RepositoryStoreBulkControllerTest.php | 7 +- .../RepositoryStoreControllerTest.php | 49 ++--- .../RepositoryUpdateBulkControllerTest.php | 2 +- .../RepositoryUpdateControllerTest.php | 10 +- .../RestifyJsSetupControllerTest.php | 5 +- tests/Feature/Filters/AdvancedFilterTest.php | 35 ++-- tests/Feature/Filters/BelongsToFilterTest.php | 168 ++++++++++-------- tests/Feature/Filters/MatchFilterTest.php | 41 +++-- tests/Feature/Filters/SortableFilterTest.php | 8 +- tests/Feature/RepositorySearchServiceTest.php | 10 +- tests/Fields/BelongsToFieldTest.php | 37 ++-- tests/Fields/BelongsToManyFieldTest.php | 8 +- tests/Fields/FileTest.php | 30 ++-- tests/Fields/HasManyTest.php | 77 ++++---- tests/Fields/HasOneFieldTest.php | 2 +- tests/Fields/ImageTest.php | 2 +- tests/Fields/MorphOneFieldTest.php | 8 +- tests/Fields/MorphToManyFieldTest.php | 6 +- tests/Getters/PerformGetterControllerTest.php | 42 +---- tests/IntegrationTest.php | 62 ++----- .../Repositories/ActionLogRepositoryTest.php | 2 +- .../RepositoryCustomPrefixTest.php | 4 +- tests/Repositories/RepositoryEventsTest.php | 2 +- tests/Unit/AdvancedFilterTest.php | 1 - tests/Unit/MatchableFilterTest.php | 1 - tests/Unit/RepositoryWithRoutesTest.php | 2 - .../factories}/CompanyFactory.php | 2 +- .../factories}/PostFactory.php | 2 +- .../factories}/RoleFactory.php | 2 +- .../factories}/UserFactory.php | 2 +- .../2017_10_10_000000_create_users_table.php | 0 ...10_000005_create_password_resets_table.php | 0 ...2_22_000005_create_Company_user__table.php | 0 ...19_12_22_000005_create_companies_table.php | 0 .../2019_12_22_000005_create_posts_table.php | 0 .../2019_12_22_000006_create_roles_table.php | 0 54 files changed, 480 insertions(+), 495 deletions(-) delete mode 100644 phpunit.xml create mode 100644 tests/Concerns/Mockers.php rename tests/{Factories => database/factories}/CompanyFactory.php (84%) rename tests/{Factories => database/factories}/PostFactory.php (93%) rename tests/{Factories => database/factories}/RoleFactory.php (83%) rename tests/{Factories => database/factories}/UserFactory.php (92%) rename tests/{Migrations => database/migrations}/2017_10_10_000000_create_users_table.php (100%) rename tests/{Migrations => database/migrations}/2017_10_10_000005_create_password_resets_table.php (100%) rename tests/{Migrations => database/migrations}/2019_12_22_000005_create_Company_user__table.php (100%) rename tests/{Migrations => database/migrations}/2019_12_22_000005_create_companies_table.php (100%) rename tests/{Migrations => database/migrations}/2019_12_22_000005_create_posts_table.php (100%) rename tests/{Migrations => database/migrations}/2019_12_22_000006_create_roles_table.php (100%) diff --git a/composer.json b/composer.json index a4dac8105..8d5ebd8a3 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "autoload-dev": { "psr-4": { "Binaryk\\LaravelRestify\\Tests\\": "tests", - "Binaryk\\LaravelRestify\\Tests\\Factories\\": "tests/Factories" + "Binaryk\\LaravelRestify\\Tests\\Database\\Factories\\": "tests/database/factories" } }, "scripts": { diff --git a/database/migrations/create_action_logs_table.php.stub b/database/migrations/create_action_logs_table.php.stub index 9b83f79bf..06d8f151c 100644 --- a/database/migrations/create_action_logs_table.php.stub +++ b/database/migrations/create_action_logs_table.php.stub @@ -4,7 +4,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -class CreateActionLogsTable extends Migration +return new class extends Migration { /** * Run the migrations. @@ -45,4 +45,4 @@ class CreateActionLogsTable extends Migration { Schema::dropIfExists('action_logs'); } -} +}; diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index ad975d9af..000000000 --- a/phpunit.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - tests - - - - - src/ - - src/Commands - - - - - - - - - - - - - - - diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ad975d9af..650ed588e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -14,23 +14,11 @@ tests - - - src/ - - src/Commands - - - - - - - - - - + + + diff --git a/src/Repositories/Concerns/Testing.php b/src/Repositories/Concerns/Testing.php index 73988586b..b13481158 100644 --- a/src/Repositories/Concerns/Testing.php +++ b/src/Repositories/Concerns/Testing.php @@ -6,6 +6,7 @@ use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; use Illuminate\Support\Str; +use Illuminate\Support\Stringable; /** * Trait Testing @@ -16,15 +17,21 @@ */ trait Testing { - public static function route(string $path = null, array $query = []): string + public static function route(string $path = null, array $query = []): Stringable { + if (str($path)->startsWith('/')) { + $path = str($path)->replaceFirst('/', '')->toString(); + } + $base = Str::replaceFirst('//', '/', Restify::path().'/'.static::uriKey()); $route = $path ? $base.'/'.$path : $base; - return $route.'?'.http_build_query($query); + return str(empty($query) + ? $route + : $route.'?'.http_build_query($query)); } /** diff --git a/src/Restify.php b/src/Restify.php index fc93bd2d5..0f58557c0 100644 --- a/src/Restify.php +++ b/src/Restify.php @@ -165,13 +165,17 @@ public static function repositoriesFrom(string $directory): void * @param null $plus * @return string */ - public static function path($plus = null) + public static function path($plus = null, array $query = []) { if (! is_null($plus)) { - return config('restify.base', '/restify-api').'/'.$plus; + return empty($query) + ? config('restify.base', '/restify-api').'/'.$plus + : config('restify.base', '/restify-api').'/'.$plus.'?'.http_build_query($query); } - return config('restify.base', '/restify-api'); + return empty($query) + ? config('restify.base', '/restify-api') + : config('restify.base', '/restify-api').'?'.http_build_query($query); } /** diff --git a/tests/Actions/FieldActionTest.php b/tests/Actions/FieldActionTest.php index 9c3afc631..6a6f6357d 100644 --- a/tests/Actions/FieldActionTest.php +++ b/tests/Actions/FieldActionTest.php @@ -23,7 +23,7 @@ public function handle(RestifyRequest $request, Post $post) $description = $request->input('description'); $post->update([ - 'description' => 'Actionable ' . $description, + 'description' => 'Actionable '.$description, ]); } }; @@ -37,11 +37,11 @@ public function handle(RestifyRequest $request, Post $post) ]); $this - ->withoutExceptionHandling() ->postJson(PostRepository::route(), [ 'description' => 'Description', 'title' => $updated = 'Title', ]) + ->assertCreated() ->assertJson( fn (AssertableJson $json) => $json ->where('data.attributes.title', $updated) @@ -61,7 +61,7 @@ public function handle(RestifyRequest $request, Post $post, int $row) $description = data_get($request[$row], 'description'); $post->update([ - 'description' => 'Actionable ' . $description, + 'description' => 'Actionable '.$description, ]); } }; @@ -107,7 +107,7 @@ public function handle(RestifyRequest $request, Post $post, int $row) $description = data_get($request[$row], 'description'); $post->update([ - 'description' => 'Actionable ' . $description, + 'description' => 'Actionable '.$description, ]); } }; diff --git a/tests/Actions/ListActionsControllerTest.php b/tests/Actions/ListActionsControllerTest.php index e0e7d3804..c3e13b019 100644 --- a/tests/Actions/ListActionsControllerTest.php +++ b/tests/Actions/ListActionsControllerTest.php @@ -30,7 +30,7 @@ public function test_could_list_actions_for_given_repository(): void $_SERVER['actions.posts.invalidate'] = true; $_SERVER['actions.posts.publish.onlyOnShow'] = true; - $this->getJson('posts/1/actions') + $this->getJson(PostRepository::route('1/actions')) ->assertSuccessful() ->assertJsonCount(2, 'data') ->assertJsonStructure([ @@ -43,19 +43,19 @@ public function test_could_list_actions_for_given_repository(): void ]); } - public function test_can_list_actions_only_for_show() + public function test_can_list_actions_only_for_show(): void { $this->mockPosts(1, 2); $_SERVER['actions.posts.onlyOnShow'] = true; $_SERVER['actions.posts.publish.onlyOnShow'] = false; - $response = $this->getJson('posts/1/actions') + $response = $this->getJson(PostRepository::route('1/actions')) ->assertJsonCount(1, 'data'); $this->assertEquals('invalidate-post-action', $response->json('data.0.uriKey')); - $response = $this->getJson('posts/actions') + $response = $this->getJson(PostRepository::route('actions')) ->assertJsonCount(1, 'data'); $this->assertEquals('publish-post-action', $response->json('data.0.uriKey')); @@ -63,10 +63,10 @@ public function test_can_list_actions_only_for_show() $_SERVER['actions.posts.onlyOnShow'] = false; $_SERVER['actions.posts.publish.onlyOnShow'] = false; - $this->getJson('posts/1/actions') + $this->getJson(PostRepository::route('1/actions')) ->assertJsonCount(0, 'data'); - $response = $this->getJson('posts/actions') + $response = $this->getJson(PostRepository::route('actions')) ->assertJsonCount(2, 'data'); $this->assertEquals('publish-post-action', $response->json('data.0.uriKey')); diff --git a/tests/Actions/PerformActionControllerTest.php b/tests/Actions/PerformActionControllerTest.php index 08714c886..c9f640034 100644 --- a/tests/Actions/PerformActionControllerTest.php +++ b/tests/Actions/PerformActionControllerTest.php @@ -7,6 +7,7 @@ use Binaryk\LaravelRestify\Tests\Fixtures\Post\PublishPostAction; use Binaryk\LaravelRestify\Tests\Fixtures\User\ActivateAction; use Binaryk\LaravelRestify\Tests\Fixtures\User\DisableProfileAction; +use Binaryk\LaravelRestify\Tests\Fixtures\User\UserRepository; use Binaryk\LaravelRestify\Tests\IntegrationTest; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -23,7 +24,7 @@ public function test_could_perform_action_for_multiple_repositories(): void { $post = $this->mockPosts(1, 2); - $this->postJson('posts/action?action='.(new PublishPostAction())->uriKey(), [ + $this->postJson(PostRepository::action(PublishPostAction::class), [ 'repositories' => [ $post->first()->id, $post->last()->id, @@ -39,7 +40,7 @@ public function test_could_perform_action_for_multiple_repositories(): void $this->assertEquals(1, PublishPostAction::$applied[0][1]->id); } - public function test_could_perform_action_using_all() + public function test_could_perform_action_using_all(): void { $this->assertDatabaseCount('posts', 0); @@ -58,7 +59,7 @@ public function handle(Request $request, Collection $collection) }, ]); - $this->postJson('posts/action?action=publish', [ + $this->postJson(PostRepository::route('actions', ['action' => 'publish']), [ 'repositories' => 'all', ])->assertOk()->assertJsonFragment([ 'fromHandle' => 0, @@ -69,7 +70,7 @@ public function test_cannot_apply_a_show_action_to_index(): void { $_SERVER['actions.posts.publish.onlyOnShow'] = true; - $this->postJson('posts/action?action='.(new PublishPostAction())->uriKey(), []) + $this->postJson(PostRepository::action(PublishPostAction::class), []) ->assertNotFound(); } @@ -77,7 +78,7 @@ public function test_show_action_not_need_repositories(): void { $users = $this->mockUsers(); - $this->postJson('users/'.$users->first()->id.'/action?action='.(new ActivateAction())->uriKey()) + $this->postJson(UserRepository::action(ActivateAction::class, $users->first()->id)) ->assertSuccessful() ->assertJsonStructure([ 'data', @@ -88,7 +89,7 @@ public function test_show_action_not_need_repositories(): void public function test_could_perform_standalone_action(): void { - $this->postJson('users/action?action='.(new DisableProfileAction())->uriKey()) + $this->postJson(UserRepository::action(DisableProfileAction::class)) ->assertSuccessful() ->assertJsonStructure([ 'data', diff --git a/tests/Concerns/Mockers.php b/tests/Concerns/Mockers.php new file mode 100644 index 000000000..8a27ca172 --- /dev/null +++ b/tests/Concerns/Mockers.php @@ -0,0 +1,31 @@ + User::factory()->create()) + ->merge(collect($predefinedEmails)->each(fn (string $email) => User::factory()->create([ + 'email' => $email, + ]))) + ->shuffle(); + } + + public function mockPosts($userId = null, $count = 1): Collection + { + return Collection::times($count, fn () => Post::factory()->create([ + 'user_id' => $userId, + ]))->shuffle(); + } + + protected function mockPost(array $attributes = []): Post + { + return Post::factory()->create($attributes); + } +} diff --git a/tests/Controllers/GlobalSearchControllerTest.php b/tests/Controllers/GlobalSearchControllerTest.php index 6489e0fe9..8e527e8c7 100644 --- a/tests/Controllers/GlobalSearchControllerTest.php +++ b/tests/Controllers/GlobalSearchControllerTest.php @@ -2,12 +2,14 @@ namespace Binaryk\LaravelRestify\Tests\Controllers; +use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostPolicy; use Binaryk\LaravelRestify\Tests\Fixtures\User\User; use Binaryk\LaravelRestify\Tests\IntegrationTest; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Gate; +use Illuminate\Testing\Fluent\AssertableJson; class GlobalSearchControllerTest extends IntegrationTest { @@ -22,29 +24,33 @@ public function test_global_search_returns_matches(): void $response = $this ->withoutExceptionHandling() - ->getJson('search?search=Second'); + ->getJson(Restify::path('search', [ + 'search' => 'Second', + ])); $this->assertCount(2, $response->json('data')); $this->assertEquals('users', $response->json('data.1.repositoryName')); $this->assertEquals('Second post', $response->json('data.0.title')); } - public function test_global_search_filter_out_unauthorized_repositories() + public function test_global_search_filter_out_unauthorized_repositories(): void { Gate::policy(Post::class, PostPolicy::class); $_SERVER['restify.post.allowRestify'] = false; - Post::factory()->create(); - User::factory()->create(); + $this->mockUsers(); + $this->mockPosts(); - $response = $this - ->withoutExceptionHandling() - ->getJson('search?search=1'); - - $this->assertCount(1, $response->json('data')); + $this->getJson(Restify::path('search', [ + 'search' => 1, + ]))->assertJson( + fn (AssertableJson $json) => $json + ->count('data', 1) + ->etc() + ); - $_SERVER['restify.post.allowRestify'] = null; + $_SERVER['restify.post.allowRestify'] = true; } public function test_global_search_filter_will_filter_with_index_query(): void @@ -56,11 +62,13 @@ public function test_global_search_filter_will_filter_with_index_query(): void Post::factory()->create(['title' => 'First post']); User::factory()->create(['name' => 'First user']); - $response = $this - ->withoutExceptionHandling() - ->getJson('search?search=1'); - - $this->assertCount(1, $response->json('data')); + $this->getJson(Restify::path('search', [ + 'search' => 1, + ]))->assertJson( + fn (AssertableJson $json) => $json + ->count('data', 1) + ->etc() + ); $_SERVER['restify.post.indexQueryCallback'] = null; } diff --git a/tests/Controllers/Index/NestedRepositoryControllerTest.php b/tests/Controllers/Index/NestedRepositoryControllerTest.php index 3de80871b..c4b4002be 100644 --- a/tests/Controllers/Index/NestedRepositoryControllerTest.php +++ b/tests/Controllers/Index/NestedRepositoryControllerTest.php @@ -3,8 +3,8 @@ namespace Binaryk\LaravelRestify\Tests\Controllers\Index; use Binaryk\LaravelRestify\Fields\HasMany; -use Binaryk\LaravelRestify\Tests\Factories\PostFactory; -use Binaryk\LaravelRestify\Tests\Factories\UserFactory; +use Binaryk\LaravelRestify\Tests\Database\Factories\PostFactory; +use Binaryk\LaravelRestify\Tests\Database\Factories\UserFactory; use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostPolicy; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; @@ -19,18 +19,18 @@ class NestedRepositoryControllerTest extends IntegrationTest public function it_can_list_nested(): void { UserRepository::$related = [ - 'posts' => HasMany::make('posts', PostRepository::class), + 'posts' => HasMany::make('posts', PostRepository::class), ]; PostFactory::many(5, [ 'user_id' => UserFactory::one()->id, ]); - $this->getJson('users/1/posts')->assertJsonCount(5, 'data'); + $this->getJson(UserRepository::route('1/posts'))->assertJsonCount(5, 'data'); UserRepository::$related = []; - $this->getJson('users/1/posts')->assertForbidden(); + $this->getJson(UserRepository::route('1/posts'))->assertForbidden(); } /** * @test */ @@ -41,10 +41,10 @@ public function it_can_show_nested_using_identifier(): void ]); UserRepository::$related = [ - 'posts' => HasMany::make('posts', PostRepository::class), + 'posts' => HasMany::make('posts', PostRepository::class), ]; - $this->getJson("users/$post->user_id/posts/$post->id") + $this->getJson(UserRepository::route("$post->user_id/posts/$post->id")) ->assertJson( fn (AssertableJson $json) => $json ->where('data.attributes.title', 'Post.') @@ -53,32 +53,32 @@ public function it_can_show_nested_using_identifier(): void UserRepository::$related = []; - $this->getJson("users/$post->user_id/posts/$post->id")->assertForbidden(); + $this->getJson(UserRepository::route("$post->user_id/posts/$post->id"))->assertForbidden(); } /** * @test */ public function it_can_store_nested_related(): void { UserRepository::$related = [ - 'posts' => HasMany::make('posts', PostRepository::class), + 'posts' => HasMany::make('posts', PostRepository::class), ]; $user = UserFactory::one(); - $this->postJson("users/$user->id/posts", [ + $this->postJson(UserRepository::route("$user->id/posts"), [ 'title' => $title = 'Post.', ]) ->assertStatus(201) ->assertJson( fn (AssertableJson $json) => $json - ->where('data.attributes.title', $title) - ->etc() + ->where('data.attributes.title', $title) + ->etc() ); self::assertCount(1, $user->posts()->get()); UserRepository::$related = []; - $this->postJson("users/$user->id/posts", [ + $this->postJson(UserRepository::route("$user->id/posts"), [ 'title' => 'Post.', ])->assertForbidden(); } @@ -89,20 +89,20 @@ public function it_can_store_nested_related(): void public function it_can_update_nested_related(): void { UserRepository::$related = [ - 'posts' => HasMany::make('posts', PostRepository::class), + 'posts' => HasMany::make('posts', PostRepository::class), ]; $post = PostFactory::one([ 'title' => 'Post', ]); - $this->putJson("users/$post->user_id/posts/$post->id", [ + $this->putJson(UserRepository::route("$post->user_id/posts/$post->id"), [ 'title' => $title = 'Updated.', ]) ->assertJson( fn (AssertableJson $json) => $json - ->where('data.attributes.title', $title) - ->etc() + ->where('data.attributes.title', $title) + ->etc() ); self::assertSame( @@ -112,7 +112,7 @@ public function it_can_update_nested_related(): void UserRepository::$related = []; - $this->putJson("users/$post->user_id/posts/$post->id", [ + $this->putJson(UserRepository::route("$post->user_id/posts/$post->id"), [ 'title' => 'Updated.', ])->assertForbidden(); } @@ -123,20 +123,20 @@ public function it_can_update_nested_related(): void public function it_can_delete_nested_related(): void { UserRepository::$related = [ - 'posts' => HasMany::make('posts', PostRepository::class), + 'posts' => HasMany::make('posts', PostRepository::class), ]; $post = PostFactory::one([ 'title' => 'Post', ]); - $this->deleteJson("users/$post->user_id/posts/$post->id")->assertNoContent(); + $this->deleteJson(UserRepository::route("$post->user_id/posts/$post->id"))->assertNoContent(); self::assertNull($post->fresh()); UserRepository::$related = []; - $this->deleteJson("users/$post->user_id/posts/$post->id")->assertForbidden(); + $this->deleteJson(UserRepository::route("$post->user_id/posts/$post->id"))->assertForbidden(); } /** @@ -149,13 +149,13 @@ public function it_will_apply_policies_when_nested_requested(): void Gate::policy(Post::class, PostPolicy::class); UserRepository::$related = [ - 'posts' => HasMany::make('posts', PostRepository::class), + 'posts' => HasMany::make('posts', PostRepository::class), ]; $post = PostFactory::one([ 'title' => 'Post', ]); - $this->deleteJson("users/$post->user_id/posts/$post->id")->assertForbidden(); + $this->deleteJson(UserRepository::route("$post->user_id/posts/$post->id"))->assertForbidden(); } } diff --git a/tests/Controllers/Index/RepositoryIndexControllerTest.php b/tests/Controllers/Index/RepositoryIndexControllerTest.php index dcec14b0a..5e0e1270c 100644 --- a/tests/Controllers/Index/RepositoryIndexControllerTest.php +++ b/tests/Controllers/Index/RepositoryIndexControllerTest.php @@ -5,7 +5,7 @@ use Binaryk\LaravelRestify\Fields\HasMany; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; -use Binaryk\LaravelRestify\Tests\Factories\PostFactory; +use Binaryk\LaravelRestify\Tests\Database\Factories\PostFactory; use Binaryk\LaravelRestify\Tests\Fixtures\Company\Company; use Binaryk\LaravelRestify\Tests\Fixtures\Company\CompanyRepository; use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; diff --git a/tests/Controllers/ProfileControllerTest.php b/tests/Controllers/ProfileControllerTest.php index 6ea5e8b6d..2ab8e43b9 100644 --- a/tests/Controllers/ProfileControllerTest.php +++ b/tests/Controllers/ProfileControllerTest.php @@ -3,6 +3,7 @@ namespace Binaryk\LaravelRestify\Tests\Controllers; use Binaryk\LaravelRestify\Fields\Image; +use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Tests\Fixtures\User\User; use Binaryk\LaravelRestify\Tests\Fixtures\User\UserRepository; use Binaryk\LaravelRestify\Tests\IntegrationTest; @@ -27,9 +28,9 @@ protected function setUp(): void ); } - public function test_profile_returns_authenticated_user() + public function test_profile_returns_authenticated_user(): void { - $response = $this->getJson('profile') + $response = $this->getJson(Restify::path('profile')) ->assertOk() ->assertJsonStructure([ 'data', @@ -40,9 +41,11 @@ public function test_profile_returns_authenticated_user() ]); } - public function test_profile_returns_authenticated_user_with_related_posts() + public function test_profile_returns_authenticated_user_with_related_posts(): void { - $this->getJson('profile?related=posts') + $this->getJson(Restify::path('profile', [ + 'related' => 'posts', + ])) ->assertOk() ->assertJsonStructure([ 'data' => [ @@ -57,7 +60,7 @@ public function test_profile_returns_authenticated_user_with_related_posts() public function test_profile_update() { - $response = $this->putJson('profile', [ + $response = $this->putJson(Restify::path('profile'), [ 'email' => 'contact@binarschool.com', 'name' => 'Eduard', ])->assertOk(); @@ -70,7 +73,7 @@ public function test_profile_update() public function test_profile_update_password() { - $this->putJson('profile', [ + $this->putJson(Restify::path('profile'), [ 'email' => 'contact@binarschool.com', 'name' => 'Eduard', 'password' => 'secret', @@ -86,7 +89,7 @@ public function test_profile_update_unique_email(): void 'email' => 'existing@gmail.com', ]); - $this->putJson('profile', [ + $this->putJson(Restify::path('profile'), [ 'email' => 'existing@gmail.com', 'name' => 'Eduard', ])->assertStatus(422); @@ -96,7 +99,7 @@ public function test_profile_validation_from_repository(): void { UserRepository::$canUseForProfileUpdate = true; - $this->putJson('profile', [ + $this->putJson(Restify::path('profile'), [ 'email' => 'contact@binarschool.com', 'name' => 'Ed', ]) @@ -112,7 +115,7 @@ public function test_get_profile_can_use_repository(): void { UserRepository::$canUseForProfile = true; - $this->getJson('profile') + $this->getJson(Restify::path('profile')) ->assertOk() ->assertJson( fn (AssertableJson $json) => $json @@ -126,7 +129,9 @@ public function test_profile_returns_authenticated_user_with_related_posts_via_r { UserRepository::$canUseForProfile = true; - $this->getJson('profile?related=posts') + $this->getJson(Restify::path('profile', [ + 'related' => 'posts', + ])) ->assertOk() ->assertJson( fn (AssertableJson $json) => $json @@ -146,7 +151,7 @@ public function test_profile_returns_authenticated_user_with_meta_profile_data_v 'roles' => '', ]; - $this->getJson('profile') + $this->getJson(Restify::path('profile')) ->assertJson( fn (AssertableJson $json) => $json ->has('data.attributes') @@ -159,7 +164,7 @@ public function test_profile_update_via_repository(): void { UserRepository::$canUseForProfileUpdate = true; - $this->putJson('profile', [ + $this->putJson(Restify::path('profile'), [ 'email' => $email = 'contact@binarschool.com', ]) ->assertJson( @@ -172,7 +177,7 @@ public function test_can_upload_avatar(): void { Storage::fake('customDisk'); - $mock = UserRepository::partialMock() + UserRepository::partialMock() ->shouldReceive('canUseForProfileUpdate') ->andReturnTrue() ->shouldReceive('fields') @@ -189,7 +194,7 @@ public function test_can_upload_avatar(): void ->storeAs('avatar.jpg'), ]); - $this->postJson('profile', [ + $this->postJson(Restify::path('profile'), [ 'avatar' => UploadedFile::fake()->image('image.jpg'), ])->assertOk()->assertJsonFragment([ 'avatar_original' => 'image.jpg', diff --git a/tests/Controllers/RepositoryAttachControllerTest.php b/tests/Controllers/RepositoryAttachControllerTest.php index e249f6dc9..09ead1347 100644 --- a/tests/Controllers/RepositoryAttachControllerTest.php +++ b/tests/Controllers/RepositoryAttachControllerTest.php @@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; +use Illuminate\Testing\Fluent\AssertableJson; use function PHPUnit\Framework\assertInstanceOf; class RepositoryAttachControllerTest extends IntegrationTest @@ -24,7 +25,7 @@ public function test_can_attach_repositories(): void $this->assertCount(0, Company::first()->users); - $this->postJson('companies/'.$company->id.'/attach/users', [ + $this->postJson(CompanyRepository::route("$company->id/attach/users"), [ 'users' => $user->getKey(), 'is_admin' => true, ]) @@ -50,14 +51,14 @@ public function test_cant_attach_repositories_not_authorized_to_attach(): void $_SERVER['allow_attach_users'] = false; - $this->postJson('companies/'.$company->id.'/attach/users', [ + $this->postJson(CompanyRepository::route("$company->id/attach/users"), [ 'users' => $user->getKey(), 'is_admin' => true, ])->assertForbidden(); $_SERVER['allow_attach_users'] = true; - $this->postJson('companies/'.$company->id.'/attach/users', [ + $this->postJson(CompanyRepository::route("$company->id/attach/users"), [ 'users' => $user->getKey(), 'is_admin' => true, ])->assertCreated(); @@ -80,7 +81,7 @@ public function test_attach_pivot_field_validation(): void ), ]); - $this->postJson('companies/'.$company->id.'/attach/users', [ + $this->postJson(CompanyRepository::route("$company->id/attach/users"), [ 'users' => $user->getKey(), ])->assertStatus(422)->assertJsonFragment([ 'is_admin' => [ @@ -98,17 +99,13 @@ public function test_pivot_field_present_when_show(): void $company->users()->attach($this->mockUsers()->first()->id); }); - $response = $this->getJson('companies/'.$company->id.'?related=users') - ->assertOk(); - - $this->assertSame( - true, - $response->json('data.relationships.users.0.pivots.is_admin') - ); - - $this->assertSame( - false, - $response->json('data.relationships.users.1.pivots.is_admin') + $this->getJson(CompanyRepository::route($company->id, [ + 'include' => 'users', + ]))->assertOk()->assertJson( + fn (AssertableJson $json) => $json + ->where('data.relationships.users.0.pivots.is_admin', true) + ->where('data.relationships.users.1.pivots.is_admin', false) + ->etc() ); } @@ -121,16 +118,13 @@ public function test_pivot_field_present_when_index(): void $company->users()->attach($this->mockUsers()->first()->id); }); - $response = $this->getJson('companies?related=users') - ->assertOk(); - - $this->assertSame( - true, - $response->json('data.0.relationships.users.0.pivots.is_admin') - ); - $this->assertSame( - false, - $response->json('data.0.relationships.users.1.pivots.is_admin') + $this->getJson(CompanyRepository::route(query: [ + 'include' => 'users', + ]))->assertOk()->assertJson( + fn (AssertableJson $json) => $json + ->where('data.0.relationships.users.0.pivots.is_admin', true) + ->where('data.0.relationships.users.1.pivots.is_admin', false) + ->etc() ); } @@ -141,7 +135,7 @@ public function test_attach_multiple_users_to_a_company(): void $this->assertCount(0, $company->users); - $this->postJson('companies/'.$company->id.'/attach/users', [ + $this->postJson(CompanyRepository::route("$company->id/attach/users"), [ 'users' => [1, 2], 'is_admin' => true, ])->assertCreated()->assertJsonFragment([ @@ -170,7 +164,7 @@ public function test_many_to_many_field_can_intercept_attach_authorization(): vo }), ]); - $this->postJson('companies/'.$company->id.'/attach/users', [ + $this->postJson(CompanyRepository::route("$company->id/attach/users"), [ 'users' => $user->getKey(), 'is_admin' => true, ])->assertForbidden(); @@ -194,7 +188,7 @@ public function test_many_to_many_field_can_intercept_attach_method(): void ->attachCallback(new AttachInvokable()), ]); - $this->postJson('companies/'.$company->id.'/attach/users', [ + $this->postJson(CompanyRepository::route("$company->id/attach/users"), [ 'users' => $user->getKey(), 'is_admin' => true, ])->assertOk(); @@ -222,7 +216,7 @@ public function test_repository_can_intercept_attach(): void }, ]; - $this->postJson('companies/'.$company->id.'/attach/users', [ + $this->postJson(CompanyRepository::route("$company->id/attach/users"), [ 'users' => $user->getKey(), ])->assertOk(); diff --git a/tests/Controllers/RepositoryDestroyControllerTest.php b/tests/Controllers/RepositoryDestroyControllerTest.php index bbe9f75e1..a20026ddc 100644 --- a/tests/Controllers/RepositoryDestroyControllerTest.php +++ b/tests/Controllers/RepositoryDestroyControllerTest.php @@ -26,7 +26,7 @@ public function test_destroy_works(): void $_SERVER['restify.post.delete'] = true; - $this->deleteJson('posts/'.$post->id, [ + $this->deleteJson(PostRepository::route($post->id), [ 'title' => 'Updated title', ]) ->assertStatus(204); @@ -57,7 +57,7 @@ public function test_destroying_repository_log_action(): void $_SERVER['restify.post.delete'] = true; - $this->deleteJson("posts/$post->id")->assertNoContent(); + $this->deleteJson(PostRepository::route($post->id))->assertNoContent(); $this->assertDatabaseHas('action_logs', [ 'user_id' => $this->authenticatedAs->getAuthIdentifier(), diff --git a/tests/Controllers/RepositoryDetachControllerTest.php b/tests/Controllers/RepositoryDetachControllerTest.php index 09994e024..99cea309b 100644 --- a/tests/Controllers/RepositoryDetachControllerTest.php +++ b/tests/Controllers/RepositoryDetachControllerTest.php @@ -28,7 +28,7 @@ public function test_can_detach_repositories(): void $this->assertCount(2, $company->users); - $this->postJson('companies/'.$company->id.'/detach/users', [ + $this->postJson(CompanyRepository::route("$company->id/detach/users"), [ 'users' => [1], ])->assertNoContent(); @@ -57,7 +57,7 @@ public function test_cant_detach_repositories_not_authorized_to_detach() $_SERVER['allow_detach_users'] = false; - $this->postJson('companies/'.$company->id.'/detach/users', [ + $this->postJson(CompanyRepository::route("$company->id/detach/users"), [ 'users' => [1, 2], ])->assertForbidden(); } @@ -79,7 +79,7 @@ public function test_many_to_many_field_can_intercept_detach_authorization() $company->users()->attach($this->mockUsers()->first()->id); }); - $this->postJson('companies/'.$company->id.'/detach/users', [ + $this->postJson(CompanyRepository::route("$company->id/detach/users"), [ 'users' => [1], ])->assertForbidden(); } @@ -104,7 +104,7 @@ public function test_many_to_many_field_can_intercept_detach_method() $company->users()->attach($this->mockUsers()->first()->id); }); - $this->postJson('companies/'.$company->id.'/detach/users', [ + $this->postJson(CompanyRepository::route("$company->id/detach/users"), [ 'users' => [1], ])->assertNoContent(); @@ -135,7 +135,7 @@ public function test_repository_can_intercept_detach() $company->users()->attach($this->mockUsers()->first()->id); }); - $this->postJson('companies/'.$company->id.'/detach/users', [ + $this->postJson(CompanyRepository::route("$company->id/detach/users"), [ 'users' => [1], ])->assertNoContent(); diff --git a/tests/Controllers/RepositoryFilterControllerTest.php b/tests/Controllers/RepositoryFilterControllerTest.php index d4fc71dc1..9fb6127a3 100644 --- a/tests/Controllers/RepositoryFilterControllerTest.php +++ b/tests/Controllers/RepositoryFilterControllerTest.php @@ -25,8 +25,9 @@ public function test_available_filters_contains_matches_sortables_searches(): vo ]; $this->withoutExceptionHandling(); - $this->getJson('posts/filters?include=matches,sortables,searchables') - ->dump() + $this->getJson(PostRepository::route('filters', [ + 'include' => 'matches,sortables,searchables', + ])) // 5 custom filters // 1 match filter // 1 sort @@ -60,15 +61,16 @@ public function test_available_filters_returns_only_matches_sortables_searches() 'title', ]; - $this->getJson('posts/filters?only=matches,sortables,searchables') - ->assertJsonCount(4, 'data'); + $this->getJson(PostRepository::route('filters', [ + 'only' => 'matches,sortables,searchables', + ]))->assertJsonCount(4, 'data'); - $this->getJson('posts/filters?only=matches') + $this->getJson(PostRepository::route('filters', ['only' => 'matches'])) ->assertJsonCount(1, 'data'); - $this->getJson('posts/filters?only=sortables')->assertJsonCount(1, 'data'); + $this->getJson(PostRepository::route('filters', ['only' => 'sortables']))->assertJsonCount(1, 'data'); - $this->getJson('posts/filters?only=searchables') + $this->getJson(PostRepository::route('filters', ['only' => 'searchables'])) ->assertJsonCount(2, 'data'); } } diff --git a/tests/Controllers/RepositoryStoreBulkControllerTest.php b/tests/Controllers/RepositoryStoreBulkControllerTest.php index 3a8c842b9..d0574b547 100644 --- a/tests/Controllers/RepositoryStoreBulkControllerTest.php +++ b/tests/Controllers/RepositoryStoreBulkControllerTest.php @@ -4,6 +4,7 @@ use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostPolicy; +use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; use Binaryk\LaravelRestify\Tests\IntegrationTest; use Illuminate\Support\Facades\Gate; @@ -17,7 +18,7 @@ protected function setUp(): void public function test_basic_validation_works(): void { - $this->postJson('posts/bulk', [ + $this->postJson(PostRepository::route('bulk'), [ [ 'title' => null, ], @@ -31,7 +32,7 @@ public function test_unauthorized_store_bulk(): void Gate::policy(Post::class, PostPolicy::class); - $this->postJson('posts/bulk', [ + $this->postJson(PostRepository::route('bulk'), [ [ 'title' => 'Title', 'description' => 'Title', @@ -45,7 +46,7 @@ public function test_user_can_bulk_create_posts(): void $_SERVER['restify.post.storeBulk'] = true; - $this->postJson('posts/bulk', [ + $this->postJson(PostRepository::route('bulk'), [ [ 'user_id' => $user->getKey(), 'title' => 'First post.', diff --git a/tests/Controllers/RepositoryStoreControllerTest.php b/tests/Controllers/RepositoryStoreControllerTest.php index 2032e0b86..258105978 100644 --- a/tests/Controllers/RepositoryStoreControllerTest.php +++ b/tests/Controllers/RepositoryStoreControllerTest.php @@ -19,9 +19,9 @@ protected function setUp(): void $this->authenticate(); } - public function test_basic_validation_works(): void + public function test_store_basic_validation_works(): void { - $this->postJson('posts', []) + $this->postJson(PostRepository::route(), []) ->assertStatus(422); } @@ -31,7 +31,7 @@ public function test_unauthorized_store(): void Gate::policy(Post::class, PostPolicy::class); - $this->postJson('posts', [ + $this->postJson(PostRepository::route(), [ 'title' => 'Title', 'description' => 'Title', ])->assertStatus(403); @@ -41,16 +41,16 @@ public function test_success_storing(): void { $_SERVER['restify.post.store'] = true; - $this->postJson('posts', $data = [ + $this->postJson(PostRepository::route(), $data = [ 'user_id' => ($user = $this->mockUsers()->first())->id, 'title' => $title = 'Some post title', - ])->assertCreated()->assertHeader('Location', '/posts/1') + ])->assertCreated()->assertHeader('Location', PostRepository::route(1)) ->assertJson( fn (AssertableJson $json) => $json - ->where('data.attributes.title', $title) - ->where('data.attributes.user_id', 1) - ->where('data.id', '1') - ->where('data.type', 'posts') + ->where('data.attributes.title', $title) + ->where('data.attributes.user_id', 1) + ->where('data.id', '1') + ->where('data.type', PostRepository::uriKey()) ); $this->assertDatabaseHas('posts', $data); @@ -59,16 +59,19 @@ public function test_success_storing(): void public function test_will_store_only_defined_fields_from_fieldsForStore(): void { $user = $this->mockUsers()->first(); - $response = $this->postJson('posts', [ + + $this->postJson(PostRepository::route(), [ 'user_id' => $user->getKey(), 'title' => 'Some post title', 'description' => 'A very short description', - ]) - ->assertStatus(201) - ->assertHeader('Location', '/posts/1'); - - $this->assertEquals('Some post title', $response->json('data.attributes.title')); - $this->assertNull($response->json('data.attributes.description')); + ])->assertCreated() + ->assertHeader('Location', PostRepository::route(1)) + ->assertJson( + fn (AssertableJson $json) => $json + ->missing('data.attributes.description') + ->where('data.attributes.title', 'Some post title') + ->etc() + ); } public function test_cannot_store_unauthorized_fields(): void @@ -87,9 +90,9 @@ public function test_cannot_store_unauthorized_fields(): void ]) ->assertJson( fn (AssertableJson $json) => $json - ->where('data.attributes.title', $updated) - ->where('data.attributes.description', null) - ->etc() + ->where('data.attributes.title', $updated) + ->where('data.attributes.description', null) + ->etc() ); } @@ -109,9 +112,9 @@ public function test_cannot_store_readonly_fields(): void ]) ->assertJson( fn (AssertableJson $json) => $json - ->where('data.attributes.title', $updated) - ->where('data.attributes.description', null) - ->etc() + ->where('data.attributes.title', $updated) + ->where('data.attributes.description', null) + ->etc() ); } @@ -119,7 +122,7 @@ public function test_storing_repository_log_action(): void { $this->authenticate(); - $this->postJson('posts', $data = [ + $this->postJson(PostRepository::route(), $data = [ 'title' => 'Some post title', ])->assertCreated(); diff --git a/tests/Controllers/RepositoryUpdateBulkControllerTest.php b/tests/Controllers/RepositoryUpdateBulkControllerTest.php index 60d79a4fe..e55a0434a 100644 --- a/tests/Controllers/RepositoryUpdateBulkControllerTest.php +++ b/tests/Controllers/RepositoryUpdateBulkControllerTest.php @@ -41,7 +41,7 @@ public function test_basic_update_works(): void 'title' => 'Second title', ]); - $this->postJson('posts/bulk/update', [ + $this->postJson(PostRepository::route('bulk/update'), [ [ 'id' => $post1->id, 'title' => 'Updated first title', diff --git a/tests/Controllers/RepositoryUpdateControllerTest.php b/tests/Controllers/RepositoryUpdateControllerTest.php index 15abc0f3a..44654e859 100644 --- a/tests/Controllers/RepositoryUpdateControllerTest.php +++ b/tests/Controllers/RepositoryUpdateControllerTest.php @@ -20,11 +20,11 @@ protected function setUp(): void $this->authenticate(); } - public function test_basic_update_works() + public function test_basic_simple_update_works(): void { $post = Post::factory()->create(); - $this->putJson('posts/'.$post->id, [ + $this->putJson(PostRepository::route($post->id), [ 'title' => 'Updated title', ])->assertOk(); @@ -35,7 +35,7 @@ public function test_put_works(): void { $post = Post::factory()->create(); - $this->putJson('posts/'.$post->id, [ + $this->putJson(PostRepository::route($post->id), [ 'title' => 'Updated title', ])->assertOk(); @@ -50,7 +50,7 @@ public function test_unauthorized_to_update(): void $_SERVER['restify.post.update'] = false; - $this->putJson('posts/'.$post->id, [ + $this->putJson(PostRepository::route($post->id), [ 'title' => 'Updated title', ])->assertStatus(403); } @@ -115,7 +115,7 @@ public function test_updating_repository_log_action(): void 'title' => 'Original', ]); - $this->postJson("posts/$post->id", $data = [ + $this->postJson(PostRepository::route($post->id), $data = [ 'title' => 'Title changed', ])->assertSuccessful(); diff --git a/tests/Controllers/RestifyJsSetupControllerTest.php b/tests/Controllers/RestifyJsSetupControllerTest.php index a7cca7495..9a14c5528 100644 --- a/tests/Controllers/RestifyJsSetupControllerTest.php +++ b/tests/Controllers/RestifyJsSetupControllerTest.php @@ -2,13 +2,14 @@ namespace Binaryk\LaravelRestify\Tests\Controllers; +use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Tests\IntegrationTest; class RestifyJsSetupControllerTest extends IntegrationTest { - public function test_returns_configurations() + public function test_returns_configurations(): void { - $this->getJson('restifyjs/setup') + $this->getJson(Restify::path('restifyjs/setup')) ->assertJsonStructure([ 'config' => [ 'domain', diff --git a/tests/Feature/Filters/AdvancedFilterTest.php b/tests/Feature/Filters/AdvancedFilterTest.php index 061f53826..0e8533f6a 100644 --- a/tests/Feature/Filters/AdvancedFilterTest.php +++ b/tests/Feature/Filters/AdvancedFilterTest.php @@ -34,14 +34,15 @@ public function test_filters_can_have_definition(): void 'title', ]; - $this->getJson('posts/filters?only=matches,searchables,sortables') - ->assertJson( - fn (AssertableJson $json) => $json - ->where('data.1.repository.key', 'users') - ->where('data.1.repository.label', 'Users') - ->where('data.1.repository.display_key', 'id') - ->etc() - ) + $this->getJson(PostRepository::route('filters', [ + 'only' => 'matches,searchables,sortables', + ]))->assertJson( + fn (AssertableJson $json) => $json + ->where('data.1.repository.key', 'users') + ->where('data.1.repository.label', 'Users') + ->where('data.1.repository.display_key', 'id') + ->etc() + ) ->assertJsonFragment([ 'key' => 'users', ]); @@ -67,7 +68,9 @@ public function test_value_filter_doesnt_require_value(): void ], ], JSON_THROW_ON_ERROR)); - $this->getJson('posts?filters='.$filters) + $this->getJson(PostRepository::route(query: [ + 'filters' => $filters, + ])) ->assertJson( fn (AssertableJson $json) => $json ->where('data.0.attributes.title', $expectedTitle) @@ -96,7 +99,7 @@ public function test_select_filter_validates_payload(): void ], ], JSON_THROW_ON_ERROR)); - $this->getJson(PostRepository::route(null, ['filters' => $filters])) + $this->getJson(PostRepository::route(query: ['filters' => $filters])) ->assertStatus(422); $filters = base64_encode(json_encode([ @@ -124,7 +127,9 @@ public function test_the_boolean_filter_is_applied(): void Post::factory(2)->create(['is_active' => true]); $this - ->getJson(PostRepository::uriKey().'/filters?include=matches') + ->getJson(PostRepository::route('filters', [ + 'include' => 'matches', + ])) ->assertOk() ->assertJsonFragment($booleanFilter = [ 'key' => $key = 'active-booleans', @@ -141,7 +146,9 @@ public function test_the_boolean_filter_is_applied(): void ], ], JSON_THROW_ON_ERROR)); - $this->getJson('posts?filters='.$filters) + $this->getJson(PostRepository::route(query: [ + 'filters' => $filters, + ])) ->assertOk() ->assertJsonCount(1, 'data'); } @@ -184,7 +191,9 @@ public function test_the_timestamp_filter_is_applied(): void ], ])); - $this->getJson('posts?filters='.$filters) + $this->getJson(PostRepository::route(query: [ + 'filters' => $filters, + ])) ->assertOk() ->assertJson( fn (AssertableJson $json) => $json diff --git a/tests/Feature/Filters/BelongsToFilterTest.php b/tests/Feature/Filters/BelongsToFilterTest.php index 893592ee1..edc1ffeee 100644 --- a/tests/Feature/Filters/BelongsToFilterTest.php +++ b/tests/Feature/Filters/BelongsToFilterTest.php @@ -9,18 +9,19 @@ use Binaryk\LaravelRestify\Tests\Fixtures\User\User; use Binaryk\LaravelRestify\Tests\Fixtures\User\UserRepository; use Binaryk\LaravelRestify\Tests\IntegrationTest; +use Illuminate\Testing\Fluent\AssertableJson; class BelongsToFilterTest extends IntegrationTest { public function test_can_filter_using_belongs_to_field(): void { PostRepository::$related = [ - 'user' => BelongsTo::make('user', UserRepository::class), + 'user' => BelongsTo::make('user', UserRepository::class), ]; PostRepository::$sort = [ 'users.attributes.name' => SortableFilter::make()->setColumn('users.name')->usingRelation( - BelongsTo::make('user', UserRepository::class), + BelongsTo::make('user', UserRepository::class), ), ]; @@ -44,47 +45,57 @@ public function test_can_filter_using_belongs_to_field(): void ]), ]); - $json = $this - ->getJson(PostRepository::uriKey().'?related=user&sort=-users.attributes.name&perPage=5') - ->json(); - - $this->assertSame( - 'Zez', - data_get($json, 'data.0.relationships.user.attributes.name') - ); - - $json = $this - ->getJson(PostRepository::uriKey().'?related=user&sort=-users.attributes.name&perPage=6&page=4') - ->json(); - - $this->assertSame( - 'Ame', - data_get($json, 'data.5.relationships.user.attributes.name') - ); - - $json = $this - ->getJson(PostRepository::uriKey().'?related=user&sort=users.attributes.name&perPage=5') - ->json(); - - $this->assertSame( - 'Ame', - data_get($json, 'data.0.relationships.user.attributes.name') - ); - - $json = $this - ->getJson(PostRepository::uriKey().'?related=user&sort=users.attributes.name&perPage=6&page=4') - ->json(); - - $this->assertSame( - 'Zez', - data_get($json, 'data.5.relationships.user.attributes.name') - ); + $this + ->getJson(PostRepository::route(query: [ + 'related' => 'user', + 'sort' => '-users.attributes.name', + 'perPage' => 5, + ]))->assertJson( + fn (AssertableJson $json) => $json + ->where('data.0.relationships.user.attributes.name', 'Zez') + ->etc() + ); + + $this + ->getJson(PostRepository::route(query: [ + 'related' => 'user', + 'sort' => '-users.attributes.name', + 'perPage' => 6, + 'page' => 4, + ]))->assertJson( + fn (AssertableJson $json) => $json + ->where('data.5.relationships.user.attributes.name', 'Ame') + ->etc() + ); + + $this + ->getJson(PostRepository::route(query: [ + 'related' => 'user', + 'sort' => 'users.attributes.name', + 'perPage' => 5, + ]))->assertJson( + fn (AssertableJson $json) => $json + ->where('data.0.relationships.user.attributes.name', 'Ame') + ->etc() + ); + + $this + ->getJson(PostRepository::route(query: [ + 'related' => 'user', + 'sort' => 'users.attributes.name', + 'perPage' => 6, + 'page' => 4, + ]))->assertJson( + fn (AssertableJson $json) => $json + ->where('data.5.relationships.user.attributes.name', 'Zez') + ->etc() + ); } public function test_can_filter_self_defined_belongs_to_field(): void { PostRepository::$related = [ - 'user' => BelongsTo::make('user', UserRepository::class)->sortable('name'), + 'user' => BelongsTo::make('user', UserRepository::class)->sortable('name'), ]; PostRepository::$sort = []; @@ -109,43 +120,52 @@ public function test_can_filter_self_defined_belongs_to_field(): void ]), ]); - $json = $this + $this // plural `users.attributes` - ->getJson(PostRepository::uriKey().'?related=user&sort=-users.attributes.name&perPage=5') - ->json(); - - - $this->assertSame( - 'Zez', - data_get($json, 'data.0.relationships.user.attributes.name') - ); - - $json = $this + ->getJson(PostRepository::route(query: [ + 'related' => 'user', + 'sort' => '-users.attributes.name', + 'perPage' => 5, + ]))->assertJson( + fn (AssertableJson $json) => $json + ->where('data.0.relationships.user.attributes.name', 'Zez') + ->etc() + ); + + $this // singular `user.attributes` - ->getJson(PostRepository::uriKey().'?related=user&sort=-user.attributes.name&perPage=6&page=4') - ->json(); - - $this->assertSame( - 'Ame', - data_get($json, 'data.5.relationships.user.attributes.name') - ); - - $json = $this - ->getJson(PostRepository::uriKey().'?related=user&sort=user.attributes.name&perPage=5') - ->json(); - - $this->assertSame( - 'Ame', - data_get($json, 'data.0.relationships.user.attributes.name') - ); - - $json = $this - ->getJson(PostRepository::uriKey().'?related=user&sort=user.attributes.name&perPage=6&page=4') - ->json(); - - $this->assertSame( - 'Zez', - data_get($json, 'data.5.relationships.user.attributes.name') - ); + ->getJson(PostRepository::route(query: [ + 'related' => 'user', + 'sort' => '-users.attributes.name', + 'perPage' => 6, + 'page' => 4, + ]))->assertJson( + fn (AssertableJson $json) => $json + ->where('data.5.relationships.user.attributes.name', 'Ame') + ->etc() + ); + + $this + ->getJson(PostRepository::route(query: [ + 'related' => 'user', + 'sort' => 'users.attributes.name', + 'perPage' => 5, + ]))->assertJson( + fn (AssertableJson $json) => $json + ->where('data.0.relationships.user.attributes.name', 'Ame') + ->etc() + ); + + $this + ->getJson(PostRepository::route(query: [ + 'related' => 'user', + 'sort' => 'users.attributes.name', + 'perPage' => 6, + 'page' => 4, + ]))->assertJson( + fn (AssertableJson $json) => $json + ->where('data.5.relationships.user.attributes.name', 'Zez') + ->etc() + ); } } diff --git a/tests/Feature/Filters/MatchFilterTest.php b/tests/Feature/Filters/MatchFilterTest.php index 79ed0b94d..b0808e9b5 100644 --- a/tests/Feature/Filters/MatchFilterTest.php +++ b/tests/Feature/Filters/MatchFilterTest.php @@ -42,7 +42,9 @@ public function test_match_definitions_includes_title(): void 'title' => 'string', ]; - $this->getJson('posts/filters?only=matches') + $this->getJson(PostRepository::route('filters', [ + 'only' => 'matches', + ])) ->assertJsonStructure([ 'data' => [ [ @@ -65,14 +67,19 @@ public function test_match_definition_hit_filter_method(): void 'id' => MatchFilter::make()->setType(RestifySearchable::MATCH_ARRAY), ]; - $this->getJson('users?-id=1,2,3') + $this->getJson(UserRepository::route(query: [ + '-id' => '1,2,3', + ])) + ->assertOk() ->assertJsonCount(1, 'data'); UserRepository::$match = [ 'id' => MatchFilter::make()->setType(RestifySearchable::MATCH_ARRAY), ]; - $this->getJson('users?id=1,2,3') + $this->getJson(UserRepository::route(query: [ + 'id' => '1,2,3', + ])) ->assertJsonCount(3, 'data'); } @@ -86,24 +93,24 @@ public function test_match_partially(): void 'name' => MatchFilter::make()->setType(RestifySearchable::MATCH_TEXT)->strict(), ]; - $this->getJson('users?name=John')->assertJsonCount(0, 'data'); + $this->getJson(UserRepository::route(query: ['name' => 'John']))->assertJsonCount(0, 'data'); UserRepository::$match = [ 'name' => MatchFilter::make()->setType(RestifySearchable::MATCH_TEXT)->strict(), ]; - $this->getJson('users?-name=John')->assertJsonCount(2, 'data'); + $this->getJson(UserRepository::route(query: ['-name' => 'John']))->assertJsonCount(2, 'data'); UserRepository::$match = [ 'name' => MatchFilter::make()->setType(RestifySearchable::MATCH_TEXT)->partial(), ]; - $this->getJson('users?name=John')->assertJsonCount(2, 'data'); + $this->getJson(UserRepository::route(query: ['name' => 'John']))->assertJsonCount(2, 'data'); UserRepository::$match = [ 'name' => MatchFilter::make()->setType(RestifySearchable::MATCH_TEXT)->partial(), ]; - $this->getJson('users?-name=John')->assertJsonCount(0, 'data'); + $this->getJson(UserRepository::route(query: ['-name' => 'John']))->assertJsonCount(0, 'data'); } public function test_can_match_range(): void @@ -114,7 +121,7 @@ public function test_can_match_range(): void 'id' => RestifySearchable::MATCH_BETWEEN, ]; - $this->getJson('users?id=1,3') + $this->getJson(UserRepository::route(query: ['id' => '1,3'])) ->assertJsonCount(3, 'data'); } @@ -126,10 +133,10 @@ public function test_can_match_using_json_api_recommendation(): void 'id' => RestifySearchable::MATCH_ARRAY, ]; - $this->getJson('users?filter[id]=1,2,3') + $this->getJson(UserRepository::route(query: ['filter[id]' => '1,2,3'])) ->assertJsonCount(3, 'data'); - $this->getJson('users?filter[-id]=1,2,3') + $this->getJson(UserRepository::route(query: ['filter[-id]' => '1,2,3'])) ->assertJsonCount(1, 'data'); } @@ -141,10 +148,10 @@ public function test_can_match_array(): void 'id' => RestifySearchable::MATCH_ARRAY, ]; - $this->getJson('users?id=1,2,3') + $this->getJson(UserRepository::route(query: ['id' => '1,2,3'])) ->assertJsonCount(3, 'data'); - $this->getJson('users?-id=1,2,3') + $this->getJson(UserRepository::route(query: ['-id' => '1,2,3'])) ->assertJsonCount(1, 'data'); } @@ -162,9 +169,9 @@ public function test_can_match_date(): void 'created_at' => RestifySearchable::MATCH_DATETIME, ]; - $this->getJson('users?created_at=null')->assertJsonCount(2, 'data'); + $this->getJson(UserRepository::route(query: ['created_at' => 'null']))->assertJsonCount(2, 'data'); - $this->getJson('users?created_at=2020-12-01')->assertJsonCount(3, 'data'); + $this->getJson(UserRepository::route(query: ['created_at' => '2020-12-01']))->assertJsonCount(3, 'data'); } public function test_can_match_datetime_interval(): void @@ -188,10 +195,10 @@ public function test_can_match_datetime_interval(): void $now = now()->toISOString(); $twoMonthsAgo = now()->subMonths(2)->toISOString(); - $this->getJson("users?created_at=$twoMonthsAgo,$now") + $this->getJson(UserRepository::route(query: ['created_at' => "$twoMonthsAgo,$now"])) ->assertJsonCount(2, 'data'); - $this->getJson("users?-created_at=$twoMonthsAgo,$now") + $this->getJson(UserRepository::route(query: ['-created_at' => "$twoMonthsAgo,$now"])) ->assertJsonCount(1, 'data'); } @@ -215,7 +222,7 @@ public function test_can_match_closure(): void ]; $this - ->getJson('users?is_active=true') + ->getJson(UserRepository::route(query: ['is_active' => true])) ->assertJson( fn (AssertableJson $json) => $json ->count('data', 1) diff --git a/tests/Feature/Filters/SortableFilterTest.php b/tests/Feature/Filters/SortableFilterTest.php index e9edccde7..54a4ad01e 100644 --- a/tests/Feature/Filters/SortableFilterTest.php +++ b/tests/Feature/Filters/SortableFilterTest.php @@ -23,14 +23,14 @@ public function test_can_order_using_filter_sortable_definition(): void 'name' => SortableFilter::make()->setColumn('name'), ]; - $this->assertSame('Alisa', $this->getJson('users?sort=name') + $this->assertSame('Alisa', $this->getJson(UserRepository::route(query: ['sort' => 'name',])) ->json('data.0.attributes.name')); - $this->assertSame('Zoro', $this->getJson('users?sort=name') + $this->assertSame('Zoro', $this->getJson(UserRepository::route(query: ['sort' => 'name',])) ->json('data.1.attributes.name')); - $this->assertSame('Zoro', $this->getJson('users?sort=-name') + $this->assertSame('Zoro', $this->getJson(UserRepository::route(query: ['sort' => '-name',])) ->json('data.0.attributes.name')); - $this->assertSame('Alisa', $this->getJson('users?sort=-name') + $this->assertSame('Alisa', $this->getJson(UserRepository::route(query: ['sort' => '-name',])) ->json('data.1.attributes.name')); } } diff --git a/tests/Feature/RepositorySearchServiceTest.php b/tests/Feature/RepositorySearchServiceTest.php index 7e54343ec..e7571e817 100644 --- a/tests/Feature/RepositorySearchServiceTest.php +++ b/tests/Feature/RepositorySearchServiceTest.php @@ -28,7 +28,7 @@ public function test_can_search_using_filter_searchable_definition(): void 'name' => CustomSearchableFilter::make(), ]; - $this->getJson('users?search=John')->assertJsonCount(4, 'data'); + $this->getJson(UserRepository::route(query: ['search' => 'John',]))->assertJsonCount(4, 'data'); } public function test_can_search_incase_sensitive(): void @@ -47,7 +47,7 @@ public function test_can_search_incase_sensitive(): void 'name', ]; - $this->getJson('users?search=John')->assertJsonCount(4, 'data'); + $this->getJson(UserRepository::route(query: ['search' => 'John',]))->assertJsonCount(4, 'data'); } public function test_can_search_using_belongs_to_field(): void @@ -74,7 +74,7 @@ public function test_can_search_using_belongs_to_field(): void ]), ]; - $this->getJson('posts?search=John') + $this->getJson(PostRepository::route(query: ['search' => 'John'])) ->assertJsonCount(2, 'data'); } @@ -89,10 +89,10 @@ public function test_can_match_custom_matcher(): void ]); UserRepository::$match = ['verified' => VerifiedMatcher::make()]; - $this->getJson('users?verified=true')->assertJsonCount(1, 'data'); + $this->getJson(UserRepository::route(query: ['verified' => 'true']))->assertJsonCount(1, 'data'); UserRepository::$match = ['verified' => VerifiedMatcher::make()]; - $this->getJson('users?verified=false')->assertJsonCount(2, 'data'); + $this->getJson(UserRepository::route(query: ['verified' => 'false']))->assertJsonCount(2, 'data'); } } diff --git a/tests/Fields/BelongsToFieldTest.php b/tests/Fields/BelongsToFieldTest.php index 006fa6f60..d603a33be 100644 --- a/tests/Fields/BelongsToFieldTest.php +++ b/tests/Fields/BelongsToFieldTest.php @@ -6,7 +6,7 @@ use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; -use Binaryk\LaravelRestify\Tests\Factories\PostFactory; +use Binaryk\LaravelRestify\Tests\Database\Factories\PostFactory; use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostPolicy; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; @@ -77,12 +77,13 @@ public function test_unauthorized_see_relationship(): void tap(Post::factory()->create([ 'user_id' => User::factory(), ]), function ($post) { - $this->getJson(PostWithUserRepository::uriKey()."/{$post->id}?related=user") - ->assertForbidden(); + $this->getJson(PostWithUserRepository::route($post->id, [ + 'related' => 'user', + ]))->assertForbidden(); }); } - public function test_dont_show_key_when_nullable_related() + public function test_dont_show_key_when_nullable_related(): void { $_SERVER['restify.users.show'] = true; @@ -91,7 +92,9 @@ public function test_dont_show_key_when_nullable_related() tap(Post::factory()->create([ 'user_id' => null, ]), function ($post) { - $this->getJson(PostWithUserRepository::uriKey()."/{$post->id}?related=user") + $this->getJson(PostWithUserRepository::route($post->id, [ + 'related' => 'user', + ])) ->assertJsonFragment([ 'user' => null, ]) @@ -99,10 +102,10 @@ public function test_dont_show_key_when_nullable_related() }); } - public function test_field_used_when_storing() + public function test_field_used_when_storing(): void { tap(User::factory()->create(), function ($user) { - $this->postJson(PostWithUserRepository::uriKey(), [ + $this->postJson(PostWithUserRepository::route(), [ 'title' => 'Create post with owner.', 'user' => $user->getKey(), ])->assertCreated(); @@ -126,14 +129,14 @@ public function test_unauthorized_via_callback_models_cannot_be_attached(): void ]); tap(User::factory()->create(), function ($user) { - $this->postJson(PostWithUserRepository::uriKey(), [ + $this->postJson(PostWithUserRepository::route(), [ 'title' => 'Create post with owner.', 'user' => $user->getKey(), ])->assertForbidden(); }); } - public function test_unauthorized_via_policy_models_cannot_be_attached() + public function test_unauthorized_via_policy_models_cannot_be_attached(): void { $_SERVER['restify.post.allowAttachUser'] = false; @@ -142,7 +145,7 @@ public function test_unauthorized_via_policy_models_cannot_be_attached() $this->assertDatabaseCount('posts', 0); tap(User::factory()->create(), function ($user) { - $this->postJson(PostWithUserRepository::uriKey(), [ + $this->postJson(PostWithUserRepository::route(), [ 'title' => 'Create post with owner.', 'user' => $user->getKey(), ])->assertForbidden(); @@ -151,7 +154,7 @@ public function test_unauthorized_via_policy_models_cannot_be_attached() $_SERVER['restify.post.allowAttachUser'] = true; - $this->postJson(PostWithUserRepository::uriKey(), [ + $this->postJson(PostWithUserRepository::route(), [ 'title' => 'Create post with owner.', 'user' => $user->getKey(), ])->assertCreated(); @@ -160,25 +163,25 @@ public function test_unauthorized_via_policy_models_cannot_be_attached() $this->assertDatabaseCount('posts', 1); } - public function test_unauthorized_without_authorization_method_defined_to_attach_models() + public function test_unauthorized_without_authorization_method_defined_to_attach_models(): void { Gate::policy(Post::class, PostPolicyWithoutMethod::class); tap(User::factory()->create(), function ($user) { - $this->postJson(PostWithUserRepository::uriKey(), [ + $this->postJson(PostWithUserRepository::route(), [ 'title' => 'Create post with owner.', 'user' => $user->getKey(), ])->assertForbidden(); }); } - public function test_field_used_when_updating() + public function test_field_used_when_updating(): void { tap(Post::factory()->create([ 'user_id' => User::factory(), ]), function ($post) { $newOwner = User::factory()->create(); - $this->putJson(PostWithUserRepository::uriKey()."/{$post->id}", [ + $this->putJson(PostWithUserRepository::route($post->id), [ 'title' => 'Can change post owner.', 'user' => $newOwner->id, ])->assertOk(); @@ -187,7 +190,7 @@ public function test_field_used_when_updating() }); } - public function test_unauthorized_via_policy_when_updating() + public function test_unauthorized_via_policy_when_updating(): void { $_SERVER['restify.post.allowAttachUser'] = false; @@ -198,7 +201,7 @@ public function test_unauthorized_via_policy_when_updating() ]), function ($post) { $firstOwnerId = $post->user->id; $newOwner = User::factory()->create(); - $this->putJson(PostWithUserRepository::uriKey()."/{$post->id}", [ + $this->putJson(PostWithUserRepository::route($post->id), [ 'title' => 'Can change post owner.', 'user' => $newOwner->id, ])->assertForbidden(); diff --git a/tests/Fields/BelongsToManyFieldTest.php b/tests/Fields/BelongsToManyFieldTest.php index 6ef63a3d5..b89b41e5c 100644 --- a/tests/Fields/BelongsToManyFieldTest.php +++ b/tests/Fields/BelongsToManyFieldTest.php @@ -19,7 +19,7 @@ public function test_belongs_to_many_displays_on_relationships_show(): void ); }); - $this->getJson(CompanyRepository::uriKey()."/{$company->id}?include=users") + $this->getJson(CompanyRepository::route($company->id, ['include' => 'users'])) ->assertJsonStructure([ 'data' => [ 'relationships' => [ @@ -43,7 +43,7 @@ public function test_belongs_to_many_can_hide_relationships_from_show(): void 'users' => BelongsToMany::make('users', UserRepository::class)->hideFromShow(), ]); - $this->getJson(CompanyRepository::uriKey()."/{$company->id}?related=users") + $this->getJson(CompanyRepository::route($company->id, ['include' => 'users'])) ->assertJsonStructure([ 'data' => [], ])->assertJsonMissing([ @@ -79,7 +79,7 @@ public function test_belongs_to_many_generates_nested_uri(): void ); }); - $response = $this->getJson(CompanyRepository::uriKey()."/{$company->id}/users") + $response = $this->getJson(CompanyRepository::route("$company->id/users")) ->assertOk(); $this->assertSame( @@ -97,7 +97,7 @@ public function test_belongs_to_many_ignored_when_storing(): void $user->companies()->attach($companies); - $this->postJson(CompanyRepository::uriKey(), [ + $this->postJson(CompanyRepository::route(), [ 'name' => 'Binar Code', 'users' => [1, 2], ])->assertJsonMissing([ diff --git a/tests/Fields/FileTest.php b/tests/Fields/FileTest.php index 038dad4ad..e26464c09 100644 --- a/tests/Fields/FileTest.php +++ b/tests/Fields/FileTest.php @@ -14,7 +14,7 @@ class FileTest extends IntegrationTest { - public function test_can_correctly_fill_the_main_attribute_and_store_file() + public function test_can_correctly_fill_the_main_attribute_and_store_file(): void { Storage::fake(); Storage::fake('public'); @@ -36,7 +36,7 @@ public function test_can_correctly_fill_the_main_attribute_and_store_file() Storage::disk('public')->assertExists('avatar.jpg'); } - public function test_can_upload_file() + public function test_can_upload_file(): void { Storage::fake('customDisk'); @@ -57,7 +57,7 @@ public function test_can_upload_file() $user = $this->mockUsers()->first(); - $this->postJson(UserRepository::uriKey()."/{$user->getKey()}", [ + $this->postJson(UserRepository::route($user->getKey()), [ 'avatar' => UploadedFile::fake()->image('image.jpg'), ])->assertOk()->assertJsonFragment([ 'avatar_original' => 'image.jpg', @@ -67,7 +67,7 @@ public function test_can_upload_file() Storage::disk('customDisk')->assertExists('avatar.jpg'); } - public function test_can_prune_prunable_files() + public function test_can_prune_prunable_files(): void { Storage::fake('customDisk'); @@ -93,13 +93,13 @@ public function test_can_prune_prunable_files() ->storeAs('avatar.jpg'), ]); - $this->deleteJson(UserRepository::uriKey()."/{$user->getKey()}") + $this->deleteJson(UserRepository::route($user->getKey())) ->assertNoContent(); Storage::disk('customDisk')->assertMissing('avatar.jpg'); } - public function test_cannot_prune_unpruneable_files() + public function test_cannot_prune_unpruneable_files(): void { Storage::fake('customDisk'); @@ -116,13 +116,13 @@ public function test_cannot_prune_unpruneable_files() Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg'), ]); - $this->deleteJson(UserRepository::uriKey()."/{$user->getKey()}") + $this->deleteJson(UserRepository::route($user->getKey())) ->assertNoContent(); Storage::disk('customDisk')->assertExists('avatar.jpg'); } - public function test_deletable_file_could_be_deleted() + public function test_deletable_file_could_be_deleted(): void { Storage::fake('customDisk'); @@ -139,13 +139,13 @@ public function test_deletable_file_could_be_deleted() Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg')->deletable(true), ]); - $this->deleteJson(UserRepository::uriKey()."/{$user->getKey()}/field/avatar") + $this->deleteJson(UserRepository::route($user->getKey(). '/field/avatar')) ->assertNoContent(); Storage::disk('customDisk')->assertMissing('avatar.jpg'); } - public function test_not_deletable_file_cannot_be_deleted() + public function test_not_deletable_file_cannot_be_deleted(): void { Storage::fake('customDisk'); @@ -162,11 +162,11 @@ public function test_not_deletable_file_cannot_be_deleted() Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg')->deletable(false), ]); - $this->deleteJson(UserRepository::uriKey()."/{$user->getKey()}/field/avatar") + $this->deleteJson(UserRepository::route($user->getKey(). '/field/avatar')) ->assertNotFound(); } - public function test_can_upload_file_using_storable() + public function test_can_upload_file_using_storable(): void { Storage::fake('customDisk'); @@ -180,7 +180,7 @@ public function test_can_upload_file_using_storable() $user = $this->mockUsers()->first(); - $this->postJson(UserRepository::uriKey()."/{$user->getKey()}", [ + $this->postJson(UserRepository::route($user->getKey()), [ 'avatar' => UploadedFile::fake()->image('image.jpg'), ])->assertOk()->assertJsonFragment([ 'avatar' => '/storage/avatar.jpg', @@ -189,7 +189,7 @@ public function test_can_upload_file_using_storable() Storage::disk('customDisk')->assertExists('avatar.jpg'); } - public function test_model_updating_will_replace_file() + public function test_model_updating_will_replace_file(): void { Storage::fake('customDisk'); @@ -208,7 +208,7 @@ public function test_model_updating_will_replace_file() Image::make('avatar')->disk('customDisk')->storeAs('newAvatar.jpg')->prunable(), ]); - $this->postJson(UserRepository::uriKey()."/{$user->getKey()}", [ + $this->postJson(UserRepository::route($user->getKey()), [ 'avatar' => UploadedFile::fake()->image('image.jpg'), ])->assertOk()->assertJsonFragment([ 'avatar' => '/storage/newAvatar.jpg', diff --git a/tests/Fields/HasManyTest.php b/tests/Fields/HasManyTest.php index c03806dfe..18aed126a 100644 --- a/tests/Fields/HasManyTest.php +++ b/tests/Fields/HasManyTest.php @@ -43,19 +43,20 @@ public function test_has_many_present_on_relations(): void 'user_id' => $user->getKey(), ]); - $this->getJson(UserWithPosts::uriKey()."/$user->id?related=posts") - ->assertJsonStructure([ - 'data' => [ - 'relationships' => [ - 'posts' => [ - [ - 'id', - 'attributes', - ], + $this->getJson(UserWithPosts::route($user->getKey(), [ + 'related' => 'posts', + ]))->assertJsonStructure([ + 'data' => [ + 'relationships' => [ + 'posts' => [ + [ + 'id', + 'attributes', ], ], ], - ]); + ], + ]); } public function test_has_many_could_choose_columns(): void @@ -68,19 +69,19 @@ public function test_has_many_could_choose_columns(): void 'user_id' => $user->getKey(), ]); - $this->getJson(UserWithPosts::uriKey()."/$user->id?related=posts[title]") + $this->getJson(UserWithPosts::route($user->getKey(), ['related' => 'posts[title]'])) ->assertJson( fn (AssertableJson $json) => $json - ->where('data.relationships.posts.0.attributes.title', 'Title') - ->etc() + ->where('data.relationships.posts.0.attributes.title', 'Title') + ->etc() ); - $this->getJson(UserWithPosts::uriKey()."/$user->id?related=posts[title|description]") + $this->getJson(UserWithPosts::route($user->getKey(), ['related' => 'posts[title|description]'])) ->assertJson( fn (AssertableJson $json) => $json - ->where('data.relationships.posts.0.attributes.title', 'Title') - ->where('data.relationships.posts.0.attributes.description', 'Description') - ->etc() + ->where('data.relationships.posts.0.attributes.title', 'Title') + ->where('data.relationships.posts.0.attributes.description', 'Description') + ->etc() ); } @@ -90,7 +91,7 @@ public function test_has_many_paginated_on_relation(): void $this->mockPosts($user->getKey(), 22); }); - $this->getJson(UserWithPosts::uriKey()."/{$user->getKey()}?related=posts&relatablePerPage=20") + $this->getJson(UserWithPosts::route($user->getKey(), ['related' => 'posts', 'relatablePerPage' => 20])) ->assertJsonCount(20, 'data.relationships.posts'); } @@ -103,15 +104,15 @@ public function test_has_many_filter_unauthorized_to_see_relationship_posts(): v $this->mockPosts($user->getKey(), 20); }); - $this->getJson(UserWithPosts::uriKey()."/$user->id?related=posts") + $this->getJson(UserWithPosts::route($user->getKey(), ['related' => 'posts'])) ->assertOk() ->assertJson(fn (AssertableJson $json) => $json->count('data.relationships.posts', 0)->etc()); } - public function test_field_ignored_when_storing() + public function test_field_ignored_when_storing(): void { tap(User::factory()->create(), function ($user) { - $this->postJson(UserWithPosts::uriKey(), [ + $this->postJson(UserWithPosts::route(), [ 'name' => 'Eduard Lupacescu', 'email' => 'eduard.lupacescu@binarcode.com', 'password' => 'strong!', @@ -120,7 +121,7 @@ public function test_field_ignored_when_storing() }); } - public function test_can_display_other_pages() + public function test_can_display_other_pages(): void { tap($u = $this->mockUsers()->first(), function ($user) { $this->mockPosts($user->getKey(), 20); @@ -136,7 +137,7 @@ public function test_can_display_other_pages() HasMany::make('posts', PostRepository::class), ]); - $this->getJson(UserWithPosts::uriKey()."/{$u->id}/posts?perPage=5") + $this->getJson(UserWithPosts::route("$u->id/posts", ['perPage' => 5])) ->assertJsonCount(5, 'data'); } @@ -160,11 +161,11 @@ public function test_can_apply_filters(): void HasMany::make('posts', PostRepository::class), ]); - $this->getJson(UserWithPosts::uriKey()."/{$u->id}/posts?title=wew") + $this->getJson(UserWithPosts::route("$u->id/posts", ['title' => 'wew'])) ->assertJsonCount(1, 'data'); } - public function test_filter_unauthorized_posts() + public function test_filter_unauthorized_posts(): void { $_SERVER['restify.post.show'] = false; @@ -184,16 +185,16 @@ public function test_filter_unauthorized_posts() HasMany::make('posts', PostRepository::class), ]); - $this->getJson(UserWithPosts::uriKey()."/{$u->id}/posts") + $this->getJson(UserWithPosts::route("$u->id/posts")) ->assertJsonCount(0, 'data'); $_SERVER['restify.post.allowRestify'] = false; - $this->getJson(UserWithPosts::uriKey()."/{$u->id}/posts") + $this->getJson(UserWithPosts::route("$u->id/posts")) ->assertForbidden(); } - public function test_can_store() + public function test_can_store(): void { $_SERVER['restify.post.store'] = true; @@ -212,7 +213,7 @@ public function test_can_store() HasMany::make('posts', PostRepository::class), ]); - $this->postJson(UserWithPosts::uriKey()."/{$u->id}/posts", [ + $this->postJson(UserWithPosts::route("$u->id/posts"), [ 'title' => 'Test', ])->assertCreated(); @@ -247,7 +248,7 @@ public function test_unauthorized_show(): void $post = $this->mockPosts($userId = $this->mockUsers()->first()->id, 1)->first(); - $this->getJson(UserWithPosts::uriKey()."/{$userId}/posts/{$post->id}", [ + $this->getJson(UserWithPosts::route("{$userId}/posts/{$post->id}"), [ 'title' => 'Test', ])->assertForbidden(); } @@ -260,25 +261,25 @@ public function test_404_post_from_different_owner(): void $this->mockPosts($userId = $this->mockUsers()->first()->id, 1)->first(); $secondPost = $this->mockPosts($secondUserId = $this->mockUsers()->first()->id, 1)->first(); - $this->getJson(UserWithPosts::uriKey()."/{$userId}/posts/{$secondPost->id}") + $this->getJson(UserWithPosts::route("/{$userId}/posts/{$secondPost->id}")) ->assertNotFound(); } - public function test_change_post() + public function test_change_post(): void { $_SERVER['restify.post.update'] = true; Gate::policy(Post::class, PostPolicy::class); $post = $this->mockPosts($userId = $this->mockUsers()->first()->id, 1)->first(); - $this->postJson(UserWithPosts::uriKey()."/{$userId}/posts/{$post->id}", [ + $this->postJson(UserWithPosts::route("/{$userId}/posts/{$post->id}"), [ 'title' => 'Test', ])->assertOk(); $this->assertSame('Test', $post->fresh()->title); } - public function test_delete_post() + public function test_delete_post(): void { $_SERVER['restify.post.delete'] = true; Gate::policy(Post::class, PostPolicy::class); @@ -287,14 +288,14 @@ public function test_delete_post() $this->assertDatabaseCount('posts', 1); - $this->deleteJson(UserWithPosts::uriKey()."/{$userId}/posts/{$post->id}", [ + $this->deleteJson(UserWithPosts::route("/{$userId}/posts/{$post->id}"), [ 'title' => 'Test', ])->assertNoContent(); $this->assertDatabaseCount('posts', 0); } - public function test_unauthorized_delete_post() + public function test_unauthorized_delete_post(): void { $_SERVER['restify.post.delete'] = false; Gate::policy(Post::class, PostPolicy::class); @@ -303,7 +304,7 @@ public function test_unauthorized_delete_post() $this->assertDatabaseCount('posts', 1); - $this->deleteJson(UserWithPosts::uriKey()."/{$userId}/posts/{$post->id}", [ + $this->deleteJson(UserWithPosts::route("/{$userId}/posts/{$post->id}"), [ 'title' => 'Test', ])->assertForbidden(); @@ -313,7 +314,7 @@ public function test_unauthorized_delete_post() public function test_it_validates_fields_when_storing_related(): void { $userId = $this->mockUsers()->first()->id; - $this->postJson(UserWithPosts::uriKey()."/{$userId}/posts", [ + $this->postJson(UserWithPosts::route("/{$userId}/posts"), [ /*'title' => 'Wew',*/ ])->assertStatus(422); } diff --git a/tests/Fields/HasOneFieldTest.php b/tests/Fields/HasOneFieldTest.php index f143eee4b..c78621113 100644 --- a/tests/Fields/HasOneFieldTest.php +++ b/tests/Fields/HasOneFieldTest.php @@ -77,7 +77,7 @@ public function test_field_ignored_when_storing(): void ); }); - $this->postJson(UserWithPostRepository::uriKey(), [ + $this->postJson(UserWithPostRepository::route(), [ 'name' => 'Eduard Lupacescu', 'email' => 'eduard.lupacescu@binarcode.com', 'password' => 'strong!', diff --git a/tests/Fields/ImageTest.php b/tests/Fields/ImageTest.php index c5461705a..0150d4bd8 100644 --- a/tests/Fields/ImageTest.php +++ b/tests/Fields/ImageTest.php @@ -40,7 +40,7 @@ public function test_ignore_image_default_value_when_image_exists(): void $user = $this->mockUsers()->first(); - $this->postJson(UserRepository::uriKey()."/{$user->getKey()}", [ + $this->postJson(UserRepository::route($user->getKey()), [ 'avatar' => UploadedFile::fake()->image('image.jpg'), ])->assertOk()->assertJsonFragment([ 'avatar' => '/storage/avatar.jpg', diff --git a/tests/Fields/MorphOneFieldTest.php b/tests/Fields/MorphOneFieldTest.php index c0c1d75ae..300d79cae 100644 --- a/tests/Fields/MorphOneFieldTest.php +++ b/tests/Fields/MorphOneFieldTest.php @@ -20,7 +20,7 @@ protected function setUp(): void $this->authenticate(); Restify::repositories([ - PostWithMophOneRepository::class, + PostWithMorphOneRepository::class, ]); } @@ -38,7 +38,7 @@ public function test_morph_one_present_on_show_when_specified_related(): void $relationships = $this ->withoutExceptionHandling() - ->getJson(PostWithMophOneRepository::uriKey()."/$post->id?related=user") + ->getJson(PostWithMorphOneRepository::route($post->id, ['related' => 'user'])) ->assertJsonStructure([ 'data' => [ 'relationships' => [ @@ -54,14 +54,14 @@ public function test_morph_one_present_on_show_when_specified_related(): void $this->assertNotNull($relationships); - $relationships = $this->getJson(PostWithMophOneRepository::uriKey()."/$post->id") + $relationships = $this->getJson(PostWithMorphOneRepository::route($post->id)) ->json('data.relationships'); $this->assertNull($relationships); } } -class PostWithMophOneRepository extends Repository +class PostWithMorphOneRepository extends Repository { public static $model = Post::class; diff --git a/tests/Fields/MorphToManyFieldTest.php b/tests/Fields/MorphToManyFieldTest.php index f3762d72c..1f0b52aa0 100644 --- a/tests/Fields/MorphToManyFieldTest.php +++ b/tests/Fields/MorphToManyFieldTest.php @@ -56,7 +56,7 @@ public function test_morph_to_many_works_with_belongs_to_many(): void ); }); - $this->getJson(UserWithRolesRepository::uriKey()."/$user->id?related=roles,companies") + $this->getJson(UserWithRolesRepository::route($user->id, ['related' => 'roles,companies'])) ->assertJsonStructure([ 'data' => [ 'relationships' => [ @@ -67,12 +67,12 @@ public function test_morph_to_many_works_with_belongs_to_many(): void ])->assertJsonCount(3, 'data.relationships.roles'); } - public function test_morph_to_many_ignored_when_store() + public function test_morph_to_many_ignored_when_store(): void { /** * @var User $user */ $user = User::factory()->make(); - $id = $this->postJson(UserWithRolesRepository::uriKey(), array_merge($user->toArray(), [ + $id = $this->postJson(UserWithRolesRepository::route(), array_merge($user->toArray(), [ 'password' => 'password', 'users' => [1], ]))->json('data.id'); diff --git a/tests/Getters/PerformGetterControllerTest.php b/tests/Getters/PerformGetterControllerTest.php index c74fea970..d4f89b47a 100644 --- a/tests/Getters/PerformGetterControllerTest.php +++ b/tests/Getters/PerformGetterControllerTest.php @@ -1,25 +1,15 @@ ensureLoggedIn(); - } - public function test_could_perform_getter(): void { $this @@ -27,8 +17,8 @@ public function test_could_perform_getter(): void ->assertOk() ->assertJson( fn (AssertableJson $json) => $json - ->where('message', 'it works') - ->etc() + ->where('message', 'it works') + ->etc() ); } @@ -40,30 +30,8 @@ public function test_could_perform_repository_getter(): void ->getJson(PostRepository::getter(PostsShowGetter::class, 1)) ->assertJson( fn (AssertableJson $json) => $json - ->where('message', 'show works') - ->etc() - ); - } - - public function test_unauthenticated_user_can_access_middleware_when_except_auth(): void - { - $this->markTestSkipped('will implement sometime'); - - Restify::$authUsing = static function (Request $request) { - return ! is_null($request->user()); - }; - - $this - ->withoutExceptionHandling() - ->getJson(PostRepository::getter(UnauthenticatedActionGetter::class)) - ->assertSuccessful() - ->assertJson( - fn (AssertableJson $json) => $json - ->etc() + ->where('message', 'show works') + ->etc() ); - - Restify::$authUsing = static function () { - return true; - }; } } diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index d799c90bd..39b62f3a6 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -7,7 +7,7 @@ use Binaryk\LaravelRestify\Models\ActionLogPolicy; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; -use Binaryk\LaravelRestify\RestifyApplicationServiceProvider; +use Binaryk\LaravelRestify\Tests\Concerns\Mockers; use Binaryk\LaravelRestify\Tests\Fixtures\Company\Company; use Binaryk\LaravelRestify\Tests\Fixtures\Company\CompanyPolicy; use Binaryk\LaravelRestify\Tests\Fixtures\Company\CompanyRepository; @@ -24,7 +24,6 @@ use Binaryk\LaravelRestify\Tests\Fixtures\User\UserRepository; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Database\Eloquent\Factories\Factory; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Gate; use JetBrains\PhpStorm\Pure; use Mockery; @@ -32,22 +31,23 @@ abstract class IntegrationTest extends TestCase { + use Mockers; + protected Mockery\MockInterface|User|null $authenticatedAs = null; protected function setUp(): void { parent::setUp(); - $this->loadRepositories() + $this + ->repositories() ->policies() - ->loadMigrations(); - - $this->app['config']->set('config.auth.user_model', User::class); - - $this->app->register(RestifyApplicationServiceProvider::class); + ->migrations(); Factory::guessFactoryNamesUsing( - fn (string $modelName) => 'Binaryk\\LaravelRestify\\Tests\\Factories\\'.class_basename($modelName).'Factory' + fn ( + string $modelName + ) => 'Binaryk\\LaravelRestify\\Tests\\Database\\Factories\\'.class_basename($modelName).'Factory' ); Restify::$authUsing = static function () { @@ -60,7 +60,7 @@ protected function setUp(): void protected function tearDown(): void { parent::tearDown(); - Mockery::close(); + Repository::clearResolvedInstances(); } @@ -73,31 +73,24 @@ protected function getPackageProviders($app): array protected function getEnvironmentSetUp($app): void { - $app['config']->set('database.default', 'sqlite'); - $app['config']->set('auth.providers.users.model', User::class); - $app['config']->set('restify.base', '/'); - - $app['config']->set('database.connections.sqlite', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); + config()->set('database.default', 'sqlite'); + config()->set('restify.auth.user_model', User::class); - include_once __DIR__.'/../database/migrations/create_action_logs_table.php.stub'; - (new \CreateActionLogsTable())->up(); + $migration = include __DIR__.'/../database/migrations/create_action_logs_table.php.stub'; + $migration->up(); } - protected function loadMigrations(): self + private function migrations(): self { $this->loadMigrationsFrom([ '--database' => 'sqlite', - '--path' => realpath(__DIR__.DIRECTORY_SEPARATOR.'Migrations'), + '--path' => realpath(__DIR__.'/database/migrations'), ]); return $this; } - public function loadRepositories(): self + public function repositories(): self { Restify::repositories([ UserRepository::class, @@ -122,27 +115,6 @@ protected function authenticate(Authenticatable $user = null): self return $this; } - public function mockUsers($count = 1, array $predefinedEmails = []): Collection - { - return Collection::times($count, fn ($i) => User::factory()->create()) - ->merge(collect($predefinedEmails)->each(fn (string $email) => User::factory()->create([ - 'email' => $email, - ]))) - ->shuffle(); - } - - public function mockPosts($userId = null, $count = 1): Collection - { - return Collection::times($count, fn () => Post::factory()->create([ - 'user_id' => $userId, - ]))->shuffle(); - } - - protected function mockPost(array $attributes = []): Post - { - return Post::factory()->create($attributes); - } - public function getTempDirectory($suffix = ''): string { return __DIR__.'/TestSupport/temp'.($suffix === '' ? '' : '/'.$suffix); diff --git a/tests/Repositories/ActionLogRepositoryTest.php b/tests/Repositories/ActionLogRepositoryTest.php index d1186223e..3d53abd8e 100644 --- a/tests/Repositories/ActionLogRepositoryTest.php +++ b/tests/Repositories/ActionLogRepositoryTest.php @@ -30,7 +30,7 @@ public function test_can_list_action_logs(): void $log->save(); - $this->getJson(ActionLogRepository::uriKey()) + $this->getJson(ActionLogRepository::route()) ->assertOk() ->assertJsonStructure([ 'data' => [ diff --git a/tests/Repositories/RepositoryCustomPrefixTest.php b/tests/Repositories/RepositoryCustomPrefixTest.php index f572762cf..2b203cfeb 100644 --- a/tests/Repositories/RepositoryCustomPrefixTest.php +++ b/tests/Repositories/RepositoryCustomPrefixTest.php @@ -33,13 +33,13 @@ public function test_repository_can_have_custom_prefix(): void public function test_repository_prefix_block_default_route(): void { - $this->getJson(PostRepository::uriKey()) + $this->getJson(PostRepository::route()) ->assertForbidden(); $this->getJson('api/index/'.PostRepository::uriKey()) ->assertSuccessful(); - $this->postJson(PostRepository::uriKey()) + $this->postJson(PostRepository::route()) ->assertForbidden(); } } diff --git a/tests/Repositories/RepositoryEventsTest.php b/tests/Repositories/RepositoryEventsTest.php index 42119b76b..b3ce3faf0 100644 --- a/tests/Repositories/RepositoryEventsTest.php +++ b/tests/Repositories/RepositoryEventsTest.php @@ -31,7 +31,7 @@ public function test_booted_method_invoked(): void { UserRepository::$wasBooted = false; - $this->getJson(UserRepository::uriKey()); + $this->getJson(UserRepository::route()); $this->assertTrue(UserRepository::$wasBooted); } diff --git a/tests/Unit/AdvancedFilterTest.php b/tests/Unit/AdvancedFilterTest.php index 094db54eb..bd8ded923 100644 --- a/tests/Unit/AdvancedFilterTest.php +++ b/tests/Unit/AdvancedFilterTest.php @@ -49,7 +49,6 @@ public function options(Request $request): array AssertableJson::fromArray($filter->jsonSerialize()), function (AssertableJson $json) { $json - ->dump() ->where('type', 'multiselect') ->where('advanced', true) ->where('title', 'Status filter') diff --git a/tests/Unit/MatchableFilterTest.php b/tests/Unit/MatchableFilterTest.php index 7c027600c..ac924d107 100644 --- a/tests/Unit/MatchableFilterTest.php +++ b/tests/Unit/MatchableFilterTest.php @@ -18,7 +18,6 @@ public function test_matchable_filter_has_key(): void AssertableJson::fromArray($filter->jsonSerialize()), function (AssertableJson $json) { $json - ->dump() ->where('key', 'matches') ->where('title', 'Approved At') ->where('column', 'approved_at') diff --git a/tests/Unit/RepositoryWithRoutesTest.php b/tests/Unit/RepositoryWithRoutesTest.php index af4651a49..e9d7cec51 100644 --- a/tests/Unit/RepositoryWithRoutesTest.php +++ b/tests/Unit/RepositoryWithRoutesTest.php @@ -13,8 +13,6 @@ protected function setUp(): void { parent::setUp(); - $this->loadRepositories(); - Restify::repositories([ RepositoryWithRoutes::class, WithCustomPrefix::class, diff --git a/tests/Factories/CompanyFactory.php b/tests/database/factories/CompanyFactory.php similarity index 84% rename from tests/Factories/CompanyFactory.php rename to tests/database/factories/CompanyFactory.php index 7a7e6d35c..c855cf726 100644 --- a/tests/Factories/CompanyFactory.php +++ b/tests/database/factories/CompanyFactory.php @@ -1,6 +1,6 @@ Date: Thu, 7 Jul 2022 12:50:45 +0300 Subject: [PATCH 12/42] fix: wip --- src/Traits/AuthorizableModels.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Traits/AuthorizableModels.php b/src/Traits/AuthorizableModels.php index 1b2a446d7..f52686981 100644 --- a/src/Traits/AuthorizableModels.php +++ b/src/Traits/AuthorizableModels.php @@ -58,7 +58,7 @@ public static function authorizedToUseRepository(Request $request): bool return method_exists(Gate::getPolicyFor(static::newModel()), 'allowRestify') ? Gate::check('allowRestify', get_class(static::newModel())) - : true; + : false; } /** @@ -230,7 +230,7 @@ public function authorizedToDelete(Request $request) * * @throws \Illuminate\Auth\Access\AuthorizationException */ - public function authorizeTo(Request $request, $ability) + public function authorizeTo(Request $request, $ability): void { if ($this->authorizedTo($request, $ability) === false) { throw new AuthorizationException(); @@ -244,9 +244,9 @@ public function authorizeTo(Request $request, $ability) * @param string $ability * @return bool */ - public function authorizedTo(Request $request, $ability) + public function authorizedTo(Request $request, $ability): bool { - return static::authorizable() ? Gate::check($ability, $this->resource) : true; + return static::authorizable() && Gate::check($ability, $this->resource); } /** From 08b54e6f2c29b48a4ad0532c23e4ecf2c062bc33 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Thu, 7 Jul 2022 12:56:15 +0300 Subject: [PATCH 13/42] fix: wip --- src/Traits/AuthorizableModels.php | 139 ++++++------------------------ 1 file changed, 28 insertions(+), 111 deletions(-) diff --git a/src/Traits/AuthorizableModels.php b/src/Traits/AuthorizableModels.php index f52686981..e0730a712 100644 --- a/src/Traits/AuthorizableModels.php +++ b/src/Traits/AuthorizableModels.php @@ -16,40 +16,11 @@ */ trait AuthorizableModels { - /** - * Determine if the given resource is authorizable. - * - * @return bool - */ - public static function authorizable() + public static function authorizable(): bool { return ! is_null(Gate::getPolicyFor(static::newModel())); } - /** - * Determine if the Restify is enabled for this repository. - * - * @param Request $request - * @return void - * @throws AuthorizationException - */ - public function authorizeToUseRepository(Request $request) - { - if (! static::authorizable()) { - return; - } - - if (method_exists(Gate::getPolicyFor(static::newModel()), 'allowRestify')) { - $this->authorizeTo($request, 'allowRestify'); - } - } - - /** - * Determine if the repository should be available for the given request. - * - * @param Request $request - * @return bool - */ public static function authorizedToUseRepository(Request $request): bool { if (! static::authorizable()) { @@ -62,56 +33,39 @@ public static function authorizedToUseRepository(Request $request): bool } /** - * Determine if the current user can view the given resource or throw. - * - * @param Request $request * @throws AuthorizationException */ - public function authorizeToShow(Request $request) + public function authorizeToShow(Request $request): void { $this->authorizeTo($request, 'show'); } - /** - * Determine if the current user can view the given resource. - * - * @param Request $request - * @return bool - */ - public function authorizedToShow(Request $request) + public function authorizedToShow(Request $request): bool { return $this->authorizedTo($request, 'show'); } /** - * Determine if the current user can store new repositories or throw an exception. - * - * @param Request $request - * @return void - * - * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws AuthorizationException */ - public static function authorizeToStore(Request $request) + public static function authorizeToStore(Request $request): void { if (! static::authorizedToStore($request)) { throw new AuthorizationException('Unauthorized to store.'); } } - public static function authorizeToStoreBulk(Request $request) + /** + * @throws AuthorizationException + */ + public static function authorizeToStoreBulk(Request $request): void { if (! static::authorizedToStoreBulk($request)) { throw new AuthorizationException('Unauthorized to store bulk.'); } } - /** - * Determine if the current user can store new repositories. - * - * @param Request $request - * @return bool - */ - public static function authorizedToStore(Request $request) + public static function authorizedToStore(Request $request): bool { if (static::authorizable()) { return Gate::check('store', static::guessModelClassName()); @@ -120,7 +74,7 @@ public static function authorizedToStore(Request $request) return false; } - public static function authorizedToStoreBulk(Request $request) + public static function authorizedToStoreBulk(Request $request): bool { if (static::authorizable()) { return Gate::check('storeBulk', static::guessModelClassName()); @@ -130,19 +84,14 @@ public static function authorizedToStoreBulk(Request $request) } /** - * Determine if the current user can update the given resource or throw an exception. - * - * @param Request $request - * @return void - * - * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws AuthorizationException */ - public function authorizeToUpdate(Request $request) + public function authorizeToUpdate(Request $request): void { $this->authorizeTo($request, 'update'); } - public function authorizeToAttach(Request $request, $method, $model) + public function authorizeToAttach(Request $request, $method, $model): bool { if (! static::authorizable()) { return false; @@ -164,19 +113,22 @@ public function authorizeToAttach(Request $request, $method, $model) public function authorizeToDetach(Request $request, $method, $model) { if (! static::authorizable()) { - return false; + throw new AuthorizationException(); } $authorized = method_exists(Gate::getPolicyFor($this->model()), $method) ? Gate::check($method, [$this->model(), $model]) - : true; + : false; if (false === $authorized) { throw new AuthorizationException(); } } - public function authorizeToUpdateBulk(Request $request) + /** + * @throws AuthorizationException + */ + public function authorizeToUpdateBulk(Request $request): void { $this->authorizeTo($request, 'updateBulk'); } @@ -186,75 +138,40 @@ public function authorizeToDeleteBulk(Request $request) $this->authorizeTo($request, 'deleteBulk'); } - /** - * Determine if the current user can update the given resource. - * - * @param Request $request - * @return bool - */ - public function authorizedToUpdate(Request $request) + public function authorizedToUpdate(Request $request): bool { return $this->authorizedTo($request, 'update'); } /** - * Determine if the current user can delete the given resource or throw an exception. - * - * @param Request $request - * @return void - * - * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws AuthorizationException */ - public function authorizeToDelete(Request $request) + public function authorizeToDelete(Request $request): void { $this->authorizeTo($request, 'delete'); } - /** - * Determine if the current user can delete the given resource. - * - * @param Request $request - * @return bool - */ - public function authorizedToDelete(Request $request) + public function authorizedToDelete(Request $request): bool { return $this->authorizedTo($request, 'delete'); } /** - * Determine if the current user has a given ability. - * - * @param Request $request - * @param string $ability - * @return void - * - * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws AuthorizationException */ - public function authorizeTo(Request $request, $ability): void + public function authorizeTo(Request $request, iterable|string $ability): void { if ($this->authorizedTo($request, $ability) === false) { throw new AuthorizationException(); } } - /** - * Determine if the current user can view the given resource. - * - * @param Request $request - * @param string $ability - * @return bool - */ - public function authorizedTo(Request $request, $ability): bool + public function authorizedTo(Request $request, iterable|string $ability): bool { return static::authorizable() && Gate::check($ability, $this->resource); } - /** - * Determine if the trait is used by repository or model. - * - * @return bool - */ - public static function isRepositoryContext() + public static function isRepositoryContext(): bool { return new static() instanceof Repository; } From 907906c2c72f832bc8b3704c2d29d247f7076d15 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Thu, 7 Jul 2022 12:57:58 +0300 Subject: [PATCH 14/42] fix: wip --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 16ae0ed0e..5a5cb7dea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ - [ ] Request validations should be rewritten - [ ] Revisit the `InteractWithRepositories` trait and clean model queries accordingly - [ ] Adding support for PHPStan and configure the level 4 -- [ ] Clean up all tests using AssertableJson [x] +- [x] Clean up all tests using AssertableJson [x] - [x] Make sure the `include` matches array key firstly, and secondly the relationship name ### Features From 54bba8e1bc9705e622817510054eb327e0f86440 Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Thu, 7 Jul 2022 18:57:52 +0300 Subject: [PATCH 15/42] Custom logs (#469) * fix: assertables & prototypes * Fix styling * fix: observer for logs * Fix styling * fix: wip * Fix styling * fix: custom logs * fix: wip * Fix styling * fix: sideeffect * Fix styling * fix: wip Co-authored-by: binaryk --- ROADMAP.md | 2 +- config/restify.php | 10 + .../create_action_logs_table.php.stub | 11 +- src/Actions/Action.php | 14 +- .../Controllers/PerformActionController.php | 6 +- .../PerformRepositoryActionController.php | 2 + .../Concerns/DetermineRequestType.php | 6 + .../Requests/IndexRepositoryActionRequest.php | 7 + src/Models/ActionLog.php | 67 +++-- src/Models/ActionLogObserver.php | 59 +++++ src/Models/Concerns/HasActionLogs.php | 21 ++ src/Repositories/Repository.php | 22 +- tests/Assertables/AssertableActionLog.php | 13 + tests/Assertables/AssertableModel.php | 78 ++++++ tests/Assertables/AssertablePost.php | 21 ++ tests/Assertables/CarbonMatching.php | 48 ++++ .../RepositoryDestroyControllerTest.php | 25 -- .../RepositoryStoreControllerTest.php | 67 ++--- .../RepositoryUpdateControllerTest.php | 26 -- tests/Feature/ActionLogTest.php | 179 +++++++++++++ tests/Fixtures/Post/Post.php | 10 +- tests/Fixtures/Post/PostPolicy.php | 2 +- tests/Fixtures/Post/PostRepository.php | 2 + tests/Fixtures/Post/PublishPostAction.php | 6 + tests/Fixtures/Prototypes.php | 13 + tests/IntegrationTest.php | 2 + tests/Prototypes/PostPrototype.php | 13 + tests/Prototypes/Prototypeable.php | 241 ++++++++++++++++++ 28 files changed, 822 insertions(+), 151 deletions(-) create mode 100644 src/Http/Requests/IndexRepositoryActionRequest.php create mode 100644 src/Models/ActionLogObserver.php create mode 100644 tests/Assertables/AssertableActionLog.php create mode 100644 tests/Assertables/AssertableModel.php create mode 100644 tests/Assertables/AssertablePost.php create mode 100644 tests/Assertables/CarbonMatching.php create mode 100644 tests/Fixtures/Prototypes.php create mode 100644 tests/Prototypes/PostPrototype.php create mode 100644 tests/Prototypes/Prototypeable.php diff --git a/ROADMAP.md b/ROADMAP.md index 5a5cb7dea..4ffe33481 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ ### Features -- [ ] Adding support for custom ActionLogs (ie ActionLog::register("project marked active by user Auth::id()", $project->id)) +- [x] Adding support for custom ActionLogs (ie ActionLog::register("project marked active by user Auth::id()", $project->id)) - [ ] Ensure `$with` loads relationship in `show` requests - [ ] Make sure any action isn't permitted unless the Model Policy exists - [ ] Ability to make an endpoint public using a policy method diff --git a/config/restify.php b/config/restify.php index 33965b60a..e4de6f08f 100644 --- a/config/restify.php +++ b/config/restify.php @@ -126,6 +126,16 @@ | Repository used to list logs. */ 'repository' => ActionLogRepository::class, + + /** + | Inform restify to log or not action logs. + */ + 'enable' => env('RESTIFY_ENABLE_LOGS', true), + + /** + | Inform restify to log model changes from any source, or just restify. Set to `false` to log just restify logs. + */ + 'all' => env('RESTIFY_WRITE_ALL_LOGS', false), ], /* diff --git a/database/migrations/create_action_logs_table.php.stub b/database/migrations/create_action_logs_table.php.stub index 06d8f151c..52207337d 100644 --- a/database/migrations/create_action_logs_table.php.stub +++ b/database/migrations/create_action_logs_table.php.stub @@ -20,15 +20,16 @@ return new class extends Migration $table->string('name'); $table->string('actionable_type'); $table->unsignedBigInteger('actionable_id'); - $table->string('target_type'); - $table->unsignedBigInteger('target_id'); - $table->string('model_type'); + $table->string('target_type')->nullable(); + $table->unsignedBigInteger('target_id')->nullable(); + $table->string('model_type')->nullable(); $table->unsignedBigInteger('model_id')->nullable(); - $table->text('fields'); + $table->text('fields')->nullable(); $table->string('status', 25)->default('running'); $table->text('original')->nullable(); $table->text('changes')->nullable(); - $table->text('exception'); + $table->text('exception')->nullable(); + $table->json('meta')->nullable(); $table->timestamps(); $table->index(['actionable_type', 'actionable_id']); diff --git a/src/Actions/Action.php b/src/Actions/Action.php index 6df34623e..779659e0d 100644 --- a/src/Actions/Action.php +++ b/src/Actions/Action.php @@ -176,9 +176,9 @@ public function handleRequest(ActionRequest $request) $response = $this->handle($request, $models); $models->each(function (Model $model) use ($request) { - if (in_array(HasActionLogs::class, class_uses_recursive($model), true)) { - Restify::actionLog()::forRepositoryAction($this, $model, $request->user())->save(); - } +// if (in_array(HasActionLogs::class, class_uses_recursive($model), true)) { +// Restify::actionLog()::forRepositoryAction($this, $model, $request->user())->save(); +// } }); }); }); @@ -190,14 +190,6 @@ public function handleRequest(ActionRequest $request) static::indexQuery($request, $query); })->firstOrFail() ); - - if (in_array(HasActionLogs::class, class_uses_recursive($model), true)) { - Restify::actionLog()::forRepositoryAction( - $this, - $model, - $request->user() - )->save(); - } }); } diff --git a/src/Http/Controllers/PerformActionController.php b/src/Http/Controllers/PerformActionController.php index 2f10365bd..74f763157 100644 --- a/src/Http/Controllers/PerformActionController.php +++ b/src/Http/Controllers/PerformActionController.php @@ -2,12 +2,14 @@ namespace Binaryk\LaravelRestify\Http\Controllers; -use Binaryk\LaravelRestify\Http\Requests\ActionRequest; +use Binaryk\LaravelRestify\Http\Requests\IndexRepositoryActionRequest; class PerformActionController extends RepositoryController { - public function __invoke(ActionRequest $request) + public function __invoke(IndexRepositoryActionRequest $request) { + $_SERVER['restify.requestClass'] = IndexRepositoryActionRequest::class; + $action = $request->action(); if (! $action->isStandalone()) { diff --git a/src/Http/Controllers/PerformRepositoryActionController.php b/src/Http/Controllers/PerformRepositoryActionController.php index 648c2be16..01c3a6aa1 100644 --- a/src/Http/Controllers/PerformRepositoryActionController.php +++ b/src/Http/Controllers/PerformRepositoryActionController.php @@ -8,6 +8,8 @@ class PerformRepositoryActionController extends RepositoryController { public function __invoke(RepositoryActionRequest $request) { +// $_SERVER['restify.requestClass'] = RepositoryActionRequest::class; + $action = $request->action(); return $action->handleRequest( diff --git a/src/Http/Requests/Concerns/DetermineRequestType.php b/src/Http/Requests/Concerns/DetermineRequestType.php index 717a021ce..84fd79745 100644 --- a/src/Http/Requests/Concerns/DetermineRequestType.php +++ b/src/Http/Requests/Concerns/DetermineRequestType.php @@ -2,6 +2,7 @@ namespace Binaryk\LaravelRestify\Http\Requests\Concerns; +use Binaryk\LaravelRestify\Http\Requests\ActionRequest; use Binaryk\LaravelRestify\Http\Requests\GlobalSearchRequest; use Binaryk\LaravelRestify\Http\Requests\RepositoryDestroyRequest; use Binaryk\LaravelRestify\Http\Requests\RepositoryIndexRequest; @@ -52,4 +53,9 @@ public function isUpdateBulkRequest(): bool { return $this instanceof RepositoryUpdateBulkRequest; } + + public function isActionRequest(): bool + { + return $this instanceof ActionRequest; + } } diff --git a/src/Http/Requests/IndexRepositoryActionRequest.php b/src/Http/Requests/IndexRepositoryActionRequest.php new file mode 100644 index 000000000..3dee6dd24 --- /dev/null +++ b/src/Http/Requests/IndexRepositoryActionRequest.php @@ -0,0 +1,7 @@ + 'array', 'changes' => 'array', + 'meta' => 'array', ]; public const STATUS_FINISHED = 'finished'; @@ -121,11 +124,43 @@ public static function forRepositoryAction(Action $action, Model $model, Authent 'model_id' => $model->getKey(), 'fields' => '', 'status' => static::STATUS_FINISHED, - 'original' => $model->toArray(), - 'changes' => null, + 'original' => array_intersect_key($model->getOriginal(), $model->getDirty()), + 'changes' => $model->getDirty(), 'exception' => '', 'created_at' => now(), 'updated_at' => now(), ]); } + + public static function register( + string $name, + Model $actionable, + array $attributes = [], + ?Authenticatable $user = null + ): self { + return new static(array_merge([ + 'batch_id' => (string) Str::uuid(), + 'user_id' => optional($user)->getAuthIdentifier(), + 'name' => $name, + 'actionable_type' => $actionable->getMorphClass(), + 'actionable_id' => $actionable->getKey(), + 'status' => static::STATUS_FINISHED, + 'created_at' => now(), + 'updated_at' => now(), + ], $attributes)); + } + + public function withMeta(string $key, mixed $value): self + { + $this->meta[$key] = $value; + + return $this; + } + + public function withMetas(array $metas): self + { + $this->meta = $metas; + + return $this; + } } diff --git a/src/Models/ActionLogObserver.php b/src/Models/ActionLogObserver.php new file mode 100644 index 000000000..7422012c4 --- /dev/null +++ b/src/Models/ActionLogObserver.php @@ -0,0 +1,59 @@ +tryLoggingActionRequest($model)) { + Restify::actionLog() + ->forRepositoryStored($model, request()?->user()) + ->save(); + } + } + + public function updating(Model $model): void + { + if (! $this->tryLoggingActionRequest($model)) { + Restify::actionLog() + ->forRepositoryUpdated($model, request()?->user()) + ->save(); + } + } + + public function deleted(Model $model): void + { + if (! $this->tryLoggingActionRequest($model)) { + Restify::actionLog() + ->forRepositoryDestroy($model, request()?->user()) + ->save(); + } + } + + private function tryLoggingActionRequest(Model $model): bool + { + $isPerformingAction = in_array($_SERVER['restify.requestClass'] ?? null, [ + IndexRepositoryActionRequest::class, + RepositoryActionRequest::class, + ], true); + + if ($isPerformingAction) { + try { + return Restify::actionLog() + ->forRepositoryAction(app($_SERVER['restify.requestClass'])->action(), $model, Auth::user()) + ->save(); + } catch (Throwable) { + } + } + + return false; + } +} diff --git a/src/Models/Concerns/HasActionLogs.php b/src/Models/Concerns/HasActionLogs.php index 576ea1433..0e5955ac6 100644 --- a/src/Models/Concerns/HasActionLogs.php +++ b/src/Models/Concerns/HasActionLogs.php @@ -2,10 +2,31 @@ namespace Binaryk\LaravelRestify\Models\Concerns; +use Binaryk\LaravelRestify\Models\ActionLogObserver; use Binaryk\LaravelRestify\Restify; +use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; +use Illuminate\Database\Eloquent\Model; +/** + * @mixin Model + */ trait HasActionLogs { + public static function bootHasActionLogs() + { + if (! config('restify.logs.enable')) { + return; + } + + if (Restify::isRestify(request())) { + Post::observe(ActionLogObserver::class); + } else { + if (config('restify.logs.all')) { + Post::observe(ActionLogObserver::class); + } + } + } + public function actionLogs() { return $this->morphMany(Restify::actionLog(), 'actionable'); diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 991a774f8..079618c56 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -313,7 +313,7 @@ public function withResource($resource): self /** * Resolve repository with given model. * - * @param Model $model + * @param Model $model * @return Repository */ public static function resolveWith(Model $model): Repository @@ -634,8 +634,6 @@ public function store(RestifyRequest $request) ->merge($this->collectFields($request)->forBelongsTo($request)) ); - $dirty = $this->resource->getDirty(); - if ($request->isViaRepository()) { $this->resource = $request->viaQuery()->save($this->resource); } else { @@ -648,12 +646,6 @@ public function store(RestifyRequest $request) } } - if (in_array(HasActionLogs::class, class_uses_recursive($this->resource))) { - Restify::actionLog() - ->forRepositoryStored($this->resource, $request->user(), $dirty) - ->save(); - } - $fields->each(fn (Field $field) => $field->invokeAfter($request, $this->resource)); $this @@ -726,12 +718,6 @@ public function update(RestifyRequest $request, $repositoryId) static::fillFields($request, $this->resource, $fields); - if (in_array(HasActionLogs::class, class_uses_recursive($this->resource))) { - Restify::actionLog() - ->forRepositoryUpdated($this->resource, $request->user()) - ->save(); - } - $this->resource->save(); return $fields; @@ -871,12 +857,6 @@ public function detach(RestifyRequest $request, $repositoryId, Collection $pivot public function destroy(RestifyRequest $request, $repositoryId) { $status = DB::transaction(function () use ($request) { - if (in_array(HasActionLogs::class, class_uses_recursive($this->resource))) { - Restify::actionLog() - ->forRepositoryDestroy($this->resource, $request->user()) - ->save(); - } - return $this->resource->delete(); }); diff --git a/tests/Assertables/AssertableActionLog.php b/tests/Assertables/AssertableActionLog.php new file mode 100644 index 000000000..9574b5bc4 --- /dev/null +++ b/tests/Assertables/AssertableActionLog.php @@ -0,0 +1,13 @@ +model; + } +} diff --git a/tests/Assertables/AssertableModel.php b/tests/Assertables/AssertableModel.php new file mode 100644 index 000000000..50714bca3 --- /dev/null +++ b/tests/Assertables/AssertableModel.php @@ -0,0 +1,78 @@ +model; + } + + protected function scope(string $key, Closure $callback) + { + return $this; + } + + protected function dotPath(string $key = ''): string + { + return $key; + } + + protected function prop(string $key = null) + { + if (is_null($key)) { + return $this->model->toArray(); + } + + return data_get($this->model, $key); + } + + public function refresh(): self + { + $this->model = $this->model->refresh(); + + return $this; + } + + public function assertSoftDeleted(): self + { + assertNotNull( + $this->model->getAttribute('deleted_at'), + ); + + return $this; + } + + public function whereFloat(string $column, mixed $value): self + { + return $this->where($column, (float) $value); + } + + abstract public function model(); +} diff --git a/tests/Assertables/AssertablePost.php b/tests/Assertables/AssertablePost.php new file mode 100644 index 000000000..4113718b2 --- /dev/null +++ b/tests/Assertables/AssertablePost.php @@ -0,0 +1,21 @@ +model()->actionLogs()->get()); + + return $this; + } + + public function model(): Post + { + return $this->model; + } +} diff --git a/tests/Assertables/CarbonMatching.php b/tests/Assertables/CarbonMatching.php new file mode 100644 index 000000000..8f18a730f --- /dev/null +++ b/tests/Assertables/CarbonMatching.php @@ -0,0 +1,48 @@ +has($key); + + /** * @var CarbonInterface $actual */ + $actual = $this->prop($key); + + PHPUnit::assertInstanceOf(CarbonInterface::class, $actual); + + if ($expected instanceof Closure) { + PHPUnit::assertTrue( + $expected(is_array($actual) ? Collection::make($actual) : $actual), + sprintf('Property [%s] was marked as invalid using a closure.', $this->dotPath($key)) + ); + + return $this; + } + + $this->ensureSorted($expected); + $this->ensureSorted($actual); + + PHPUnit::assertSame( + $actual->toDateString(), + $expected->toDateString(), + sprintf('Property [%s] does not match the expected value.', $this->dotPath($key)) + ); + + return $this; + } +} diff --git a/tests/Controllers/RepositoryDestroyControllerTest.php b/tests/Controllers/RepositoryDestroyControllerTest.php index a20026ddc..1116f1789 100644 --- a/tests/Controllers/RepositoryDestroyControllerTest.php +++ b/tests/Controllers/RepositoryDestroyControllerTest.php @@ -2,7 +2,6 @@ namespace Binaryk\LaravelRestify\Tests\Controllers; -use Binaryk\LaravelRestify\Models\ActionLog; use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostPolicy; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; @@ -46,28 +45,4 @@ public function test_unauthorized_to_destroy(): void $this->assertInstanceOf(Post::class, $post->refresh()); } - - public function test_destroying_repository_log_action(): void - { - $this->authenticate(); - - $post = Post::factory()->create([ - 'title' => 'Original title', - ]); - - $_SERVER['restify.post.delete'] = true; - - $this->deleteJson(PostRepository::route($post->id))->assertNoContent(); - - $this->assertDatabaseHas('action_logs', [ - 'user_id' => $this->authenticatedAs->getAuthIdentifier(), - 'name' => ActionLog::ACTION_DELETED, - 'actionable_type' => Post::class, - 'actionable_id' => $post->getKey(), - ]); - - $log = ActionLog::latest()->first(); - - $this->assertSame($post->title, data_get($log->original, 'title')); - } } diff --git a/tests/Controllers/RepositoryStoreControllerTest.php b/tests/Controllers/RepositoryStoreControllerTest.php index 258105978..6e4e4db54 100644 --- a/tests/Controllers/RepositoryStoreControllerTest.php +++ b/tests/Controllers/RepositoryStoreControllerTest.php @@ -3,13 +3,13 @@ namespace Binaryk\LaravelRestify\Tests\Controllers; use Binaryk\LaravelRestify\Fields\Field; -use Binaryk\LaravelRestify\Models\ActionLog; use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostPolicy; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; use Binaryk\LaravelRestify\Tests\IntegrationTest; use Illuminate\Support\Facades\Gate; use Illuminate\Testing\Fluent\AssertableJson; +use Illuminate\Testing\TestResponse; class RepositoryStoreControllerTest extends IntegrationTest { @@ -31,29 +31,35 @@ public function test_unauthorized_store(): void Gate::policy(Post::class, PostPolicy::class); - $this->postJson(PostRepository::route(), [ - 'title' => 'Title', - 'description' => 'Title', - ])->assertStatus(403); + $this + ->posts() + ->create(tap: function (TestResponse $response) { + $response->assertForbidden(); + }); } public function test_success_storing(): void { $_SERVER['restify.post.store'] = true; - $this->postJson(PostRepository::route(), $data = [ - 'user_id' => ($user = $this->mockUsers()->first())->id, - 'title' => $title = 'Some post title', - ])->assertCreated()->assertHeader('Location', PostRepository::route(1)) - ->assertJson( - fn (AssertableJson $json) => $json - ->where('data.attributes.title', $title) - ->where('data.attributes.user_id', 1) - ->where('data.id', '1') - ->where('data.type', PostRepository::uriKey()) - ); - - $this->assertDatabaseHas('posts', $data); + $post = $this + ->posts() + ->fake() + ->attributes([ + 'title' => $title = 'Some post title', + ]) + ->create(tap: fn (TestResponse $testResponse) => $testResponse + ->assertHeader('Location', PostRepository::route(1)) + ->assertJson( + fn (AssertableJson $json) => $json + ->where('data.attributes.title', $title) + ->where('data.attributes.user_id', 1) + ->where('data.id', '1') + ->where('data.type', PostRepository::uriKey()), + )) + ->model(); + + $this->assertModelExists($post); } public function test_will_store_only_defined_fields_from_fieldsForStore(): void @@ -68,9 +74,9 @@ public function test_will_store_only_defined_fields_from_fieldsForStore(): void ->assertHeader('Location', PostRepository::route(1)) ->assertJson( fn (AssertableJson $json) => $json - ->missing('data.attributes.description') - ->where('data.attributes.title', 'Some post title') - ->etc() + ->missing('data.attributes.description') + ->where('data.attributes.title', 'Some post title') + ->etc() ); } @@ -117,23 +123,4 @@ public function test_cannot_store_readonly_fields(): void ->etc() ); } - - public function test_storing_repository_log_action(): void - { - $this->authenticate(); - - $this->postJson(PostRepository::route(), $data = [ - 'title' => 'Some post title', - ])->assertCreated(); - - $this->assertDatabaseHas('action_logs', [ - 'user_id' => $this->authenticatedAs->getAuthIdentifier(), - 'name' => ActionLog::ACTION_CREATED, - 'actionable_type' => Post::class, - ]); - - $log = ActionLog::latest()->first(); - - $this->assertSame($data, $log->changes); - } } diff --git a/tests/Controllers/RepositoryUpdateControllerTest.php b/tests/Controllers/RepositoryUpdateControllerTest.php index 44654e859..fe4097a30 100644 --- a/tests/Controllers/RepositoryUpdateControllerTest.php +++ b/tests/Controllers/RepositoryUpdateControllerTest.php @@ -3,7 +3,6 @@ namespace Binaryk\LaravelRestify\Tests\Controllers; use Binaryk\LaravelRestify\Fields\Field; -use Binaryk\LaravelRestify\Models\ActionLog; use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostPolicy; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; @@ -106,29 +105,4 @@ public function test_cannot_update_readonly_fields(): void ->etc() ); } - - public function test_updating_repository_log_action(): void - { - $this->authenticate(); - - $post = Post::factory()->create([ - 'title' => 'Original', - ]); - - $this->postJson(PostRepository::route($post->id), $data = [ - 'title' => 'Title changed', - ])->assertSuccessful(); - - $this->assertDatabaseHas('action_logs', [ - 'user_id' => $this->authenticatedAs->getAuthIdentifier(), - 'name' => ActionLog::ACTION_UPDATED, - 'actionable_type' => Post::class, - 'actionable_id' => (string) $post->id, - ]); - - $log = ActionLog::latest()->first(); - - $this->assertSame($data, $log->changes); - $this->assertSame(['title' => 'Original'], $log->original); - } } diff --git a/tests/Feature/ActionLogTest.php b/tests/Feature/ActionLogTest.php index aada3d0a7..425bfad6f 100644 --- a/tests/Feature/ActionLogTest.php +++ b/tests/Feature/ActionLogTest.php @@ -4,8 +4,16 @@ use Binaryk\LaravelRestify\Actions\Action; use Binaryk\LaravelRestify\Models\ActionLog; +use Binaryk\LaravelRestify\Models\ActionLogObserver; +use Binaryk\LaravelRestify\Tests\Assertables\AssertableActionLog; +use Binaryk\LaravelRestify\Tests\Assertables\AssertablePost; +use Binaryk\LaravelRestify\Tests\Database\Factories\PostFactory; +use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; +use Binaryk\LaravelRestify\Tests\Fixtures\Post\PublishPostAction; use Binaryk\LaravelRestify\Tests\Fixtures\User\User; use Binaryk\LaravelRestify\Tests\IntegrationTest; +use Illuminate\Support\Facades\Auth; +use Illuminate\Testing\TestResponse; class ActionLogTest extends IntegrationTest { @@ -119,4 +127,175 @@ public function test_can_create_log_for_repository_custom_action() $this->assertDatabaseCount('action_logs', 1); } + + public function test_store_log_on_store_request(): void + { + $post = $this + ->posts() + ->attributes(['title' => 'Title', 'user_id' => 1]) + ->create( + fn (AssertablePost $assertablePost) => $assertablePost + ->hasActionLog() + ->etc() + )->model(); + + $actionLog = AssertableActionLog::make($post->actionLogs()->latest()->first()); + + $actionLog + ->where('name', ActionLog::ACTION_CREATED) + ->where('status', ActionLog::STATUS_FINISHED) + ->where('actionable_type', get_class($post)) + ->where('actionable_id', $post->getKey()) + ->where('original', '') + ->where('changes.user_id', 1) + ->where('changes.title', 'Title') + ->etc(); + } + + public function test_store_log_on_update_request(): void + { + $post = $this + ->posts() + ->attributes(['title' => 'Title']) + ->create() + ->attributes(['title' => 'Updated post']) + ->update( + assertable: fn (AssertablePost $assertablePost) => $assertablePost + ->hasActionLog(2) + ->etc() + )->model(); + + $actionLog = AssertableActionLog::make($post->actionLogs()->latest('id')->first()); + + $actionLog + ->where('name', ActionLog::ACTION_UPDATED) + ->where('status', ActionLog::STATUS_FINISHED) + ->where('actionable_type', get_class($post)) + ->where('actionable_id', $post->getKey()) + ->where('original', ['title' => 'Title']) + ->where('changes', ['title' => 'Updated post']) + ->where('user_id', Auth::id()) + ->etc(); + } + + public function test_store_log_on_destroy_request(): void + { + $_SERVER['restify.post.delete'] = true; + + $post = PostFactory::one(['title' => 'Title']); + + $this->assertEmpty($post->actionLogs()->get()); + + Post::observe(ActionLogObserver::class); + + $this + ->posts() + ->attributes(['title' => 'Updated post']) + ->destroy( + key: $post->getKey(), + tap: fn (TestResponse $assertablePost) => $assertablePost + ->assertNoContent() + ); + + $actionLog = AssertableActionLog::make($post->actionLogs()->latest()->first()); + + $actionLog + ->where('name', ActionLog::ACTION_DELETED) + ->where('status', ActionLog::STATUS_FINISHED) + ->where('actionable_type', get_class($post)) + ->where('actionable_id', $post->getKey()) + ->where('original.id', $post->getKey()) + ->where('original.title', $post->title) + ->where('changes', null) + ->where('user_id', Auth::id()) + ->etc(); + } + + public function test_store_log_when_creating_outside_restify(): void + { + config()->set('restify.logs.all', true); + + $post = PostFactory::one(['title' => 'Title']); + + $this->assertCount(1, $post->actionLogs()->get()); + + $actionLog = AssertableActionLog::make($post->actionLogs()->latest()->first()); + + $actionLog + ->where('name', ActionLog::ACTION_CREATED) + ->where('status', ActionLog::STATUS_FINISHED) + ->where('actionable_type', get_class($post)) + ->where('actionable_id', $post->getKey()) + ->where('original', '') + ->where('changes.title', 'Title') + ->where('user_id', null) + ->etc(); + + config()->set('restify.logs.all', false); + } + + public function test_store_log_on_action_request(): void + { + $_SERVER['actions.posts.publish.onlyOnShow'] = false; + + Post::observe(ActionLogObserver::class); + + $post = $this + ->posts() + ->attributes(['title' => 'Title', 'user_id' => 1, 'is_active' => false]) + ->create() + ->model(); + + $this + ->posts() + ->runAction(PublishPostAction::class, [ + 'repositories' => [1], + ]); + + $this->assertTrue($post->fresh()->is_active); + + $actionLog = AssertableActionLog::make($post->actionLogs()->latest('id')->first()); + + $actionLog + ->where('name', PublishPostAction::$uriKey) + ->where('status', ActionLog::STATUS_FINISHED) + ->where('actionable_type', get_class($post)) + ->where('actionable_id', $post->getKey()) + ->where('original.is_active', false) + ->where('changes.is_active', true) + ->where('user_id', Auth::id()) + ->etc(); + } + + public function test_can_store_custom_logs(): void + { + $post = PostFactory::one(); + + ActionLog::register('Activated post', $post, [], $this->authenticatedAs)->save(); + + $this->assertDatabaseHas('action_logs', [ + 'name' => 'Activated post', + 'actionable_type' => $post::class, + 'actionable_id' => $post->getKey(), + ]); + } + + public function test_store_all_logs_when_enabled_and_go_through_restify_and_mutate_from_side_effect(): void + { + $post = $this + ->posts() + ->attributes(['title' => 'Title']) + ->create() + ->attributes(['title' => 'Updated post']) + ->update( + assertable: fn (AssertablePost $assertablePost) => $assertablePost + ->hasActionLog(2) + ->etc() + ) + ->model(); + + $post->update(['title' => 'A title set outside of restify.']); + + $this->assertCount(3, $post->actionLogs()->get()); + } } diff --git a/tests/Fixtures/Post/Post.php b/tests/Fixtures/Post/Post.php index 32fa92964..4e6d69b1d 100644 --- a/tests/Fixtures/Post/Post.php +++ b/tests/Fixtures/Post/Post.php @@ -13,10 +13,10 @@ * @property mixed $id * @property mixed $user_id * @property mixed $image - * @property mixed $title - * @property mixed $description + * @property string $title + * @property string $description * @property mixed $category - * @property mixed $is_active + * @property bool $is_active */ class Post extends Model { @@ -33,6 +33,10 @@ class Post extends Model 'is_active', ]; + protected $casts = [ + 'is_active' => 'bool', + ]; + public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/tests/Fixtures/Post/PostPolicy.php b/tests/Fixtures/Post/PostPolicy.php index fa0091bd8..889f5f745 100644 --- a/tests/Fixtures/Post/PostPolicy.php +++ b/tests/Fixtures/Post/PostPolicy.php @@ -39,7 +39,7 @@ public function deleteBulk($user, $post) return $_SERVER['restify.post.deleteBulk'] ?? true; } - public function delete($user, $post) + public function delete($user = null, $post) { return $_SERVER['restify.post.delete'] ?? true; } diff --git a/tests/Fixtures/Post/PostRepository.php b/tests/Fixtures/Post/PostRepository.php index 741a15481..c173070f5 100644 --- a/tests/Fixtures/Post/PostRepository.php +++ b/tests/Fixtures/Post/PostRepository.php @@ -59,6 +59,8 @@ public function fieldsForStore(RestifyRequest $request): array field('title')->storingRules('required')->messages([ 'required' => 'This field is required', ]), + + field('is_active'), ]; } diff --git a/tests/Fixtures/Post/PublishPostAction.php b/tests/Fixtures/Post/PublishPostAction.php index 6b335a48e..2320f0f09 100644 --- a/tests/Fixtures/Post/PublishPostAction.php +++ b/tests/Fixtures/Post/PublishPostAction.php @@ -12,6 +12,8 @@ class PublishPostAction extends Action { public static $applied = []; + public static $uriKey = 'publish-post-action'; + public static function indexQuery(RestifyRequest $request, $query) { $query->whereNotNull('published_at'); @@ -21,6 +23,10 @@ public function handle(ActionRequest $request, Collection $models): JsonResponse { static::$applied[] = $models; + $models->each(fn (Post $post) => $post->update([ + 'is_active' => true, + ])); + return data(['succes' => 'true']); } } diff --git a/tests/Fixtures/Prototypes.php b/tests/Fixtures/Prototypes.php new file mode 100644 index 000000000..a92c11f9b --- /dev/null +++ b/tests/Fixtures/Prototypes.php @@ -0,0 +1,13 @@ +model; + } +} diff --git a/tests/Prototypes/Prototypeable.php b/tests/Prototypes/Prototypeable.php new file mode 100644 index 000000000..51050446a --- /dev/null +++ b/tests/Prototypes/Prototypeable.php @@ -0,0 +1,241 @@ +attributes = static::modelClass()::factory($attributes)->make()->toArray(); + + return $this; + } + + public function attributes(array $attributes = []): self + { + $this->attributes = array_merge($this->attributes, $attributes); + + return $this; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + public static function modelClass(): string|Model + { + $model = Str::of(class_basename(get_called_class())) + ->replaceLast('Prototype', '') + ->trim() + ->singular() + ->__toString(); + + + if (class_exists($guessedClass = "Binaryk\\LaravelRestify\\Tests\Fixtures\\{$model}\\{$model}")) { + return $guessedClass; + } + + if (! isset(static::$modelClass)) { + abort(502, '$modelClass is not defined.'); + } + + return static::$modelClass; + } + + public static function repositoryClass(): string|Repository|null + { + if (class_exists($guessedClass = Restify::repositoryForModel(static::modelClass()))) { + return $guessedClass; + } + + if (! isset(static::$repositoryClass)) { + return null; + } + + return static::$repositoryClass; + } + + public static function assertableClass(): string|AssertableModel|null + { + if (class_exists($guessedClass = '\\Binaryk\\LaravelRestify\\Tests\\Assertables\\Assertable'.static::baseModelClass())) { + return $guessedClass; + } + + if (! isset(static::$repositoryClass)) { + return null; + } + + return static::$assertableClass; + } + + public static function baseModelClass(): string + { + return class_basename(static::modelClass()); + } + + private function ensureRepositoryClassDefined(): void + { + abort_unless((bool) static::repositoryClass(), 400, '$repositoryClass is not defined.'); + } + + private function ensureModelClassDefined(): void + { + abort_unless((bool) static::modelClass(), 400, '$modelClass is not defined.'); + } + + public function get(): TestResponse + { + $this->ensureRepositoryClassDefined(); + + return $this->test->getJson(static::repositoryClass()::route()); + } + + public function create(Closure $assertable = null, Closure $tap = null): self + { + $this->ensureRepositoryClassDefined(); + + $id = $this->test->postJson(static::repositoryClass()::route(), $this->getAttributes()) + ->tap($tap ?? fn () => '') + ->json('data.id'); + + return $this->wirteableCallback($id, $assertable); + } + + public function update(string|int $key = null, Closure $assertable = null, Closure $tap = null): self + { + $key = $key ?? $this->model()->getKey(); + + $id = $this->test->postJson(static::repositoryClass()::route($key), $this->getAttributes()) + ->tap($tap ?? fn () => '') + ->json('data.id'); + + return $this->wirteableCallback($id, $assertable); + } + + public function destroy(string|int $key = null, Closure $assertable = null, Closure $tap = null): self + { + $key = $key ?? $this->model()->getKey(); + + $this->test + ->deleteJson(static::repositoryClass()::route($key)) + ->tap($tap ?? fn () => ''); + + return $this; + } + + public function runAction(string $actionClass, array $payload = [], Closure $cb = null): self + { + abort_unless(is_subclass_of($actionClass, Action::class), 400, __('Invalid class instance.')); + + abort_unless((bool) static::repositoryClass(), 502, __('Undefined class $repositoryClass.')); + + $call = $this->test->postJson(static::repositoryClass()::action( + $actionClass, + $this->model() + ? $this->model()->getKey() + : null, + ), $payload)->assertOk(); + + if (is_callable($cb)) { + $cb($call); + } + + return $this; + } + + protected function wirteableCallback(mixed $key, Closure $cb = null): self + { + if (is_null($key)) { + return $this; + } + + if (! static::modelClass() && is_callable($cb)) { + $this->ensureModelClassDefined(); + } + + if (! static::modelClass()) { + return $this; + } + + $this->model = static::modelClass()::find($key); + + if (method_exists($this, 'setModel')) { + $this->setModel($this->model()); + } + + if (is_callable($cb) && static::assertableClass()) { + $cb(static::assertableClass()::make($this->model())); + } + + return $this; + } + + public function setModel(?Model $model): self + { + $this->model = $model; + + return $this; + } + + public function model() + { + return $this->model; + } + + public function fresh(): Model + { + abort_unless((bool) $this->model(), 400, __('Model was not created.')); + + return $this->model()?->fresh(); + } + + public function assert(Closure $cb): self + { + abort_unless((bool) static::assertableClass(), 502, __('Undefined class $assertableClass.')); + + $cb( + static::assertableClass()::make($this->fresh()), + ); + + return $this; + } + + public function dd($prop = null) + { + dd($prop ? $this->{$prop} : $this); + } + + public function ddd() + { + dd($this->jsonSerialize()); + } + + public function jsonSerialize(): array + { + return []; + } +} From 23d30cec8cbc99157c997f3747d77ce0ce888a09 Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Thu, 7 Jul 2022 19:16:18 +0300 Subject: [PATCH 16/42] With eager loading (#470) * fix: adding with for the show * fix: cleanup responses using helper * Fix styling Co-authored-by: binaryk --- .../Controllers/RepositoryShowController.php | 2 +- src/Repositories/Repository.php | 33 +++++-------------- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/src/Http/Controllers/RepositoryShowController.php b/src/Http/Controllers/RepositoryShowController.php index b5fe85452..5107620e4 100644 --- a/src/Http/Controllers/RepositoryShowController.php +++ b/src/Http/Controllers/RepositoryShowController.php @@ -14,7 +14,7 @@ public function __invoke(RepositoryShowRequest $request): Response return $request->repositoryWith(tap($request->modelQuery(), fn ($query) => $repository::showQuery( $request, $repository::mainQuery($request, $query->with($repository::withs())) - ))->firstOrFail()) + ))->with($repository::withs())->firstOrFail()) ->allowToShow($request) ->show($request, request('repositoryId')); } diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 079618c56..83de3ebba 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -618,7 +618,7 @@ public function resolveIndexLinks(RestifyRequest $request, Collection $items, ar public function show(RestifyRequest $request, $repositoryId) { - return $this->response()->data($this->serializeForShow($request)); + return data($this->serializeForShow($request)); } public function store(RestifyRequest $request) @@ -660,12 +660,7 @@ public function store(RestifyRequest $request) call_user_func([static::class, 'stored'], $this->resource, $request); } - return $this->response() - ->created() - ->header('Location', static::uriTo($this->resource)) - ->data($this->serializeForShow( - $request, - )); + return data($this->serializeForShow($request), 201, ['Location' => static::uriTo($this->resource)]); } public function storeBulk(RepositoryStoreBulkRequest $request) @@ -702,9 +697,7 @@ public function storeBulk(RepositoryStoreBulkRequest $request) static::storedBulk($entities, $request); - return $this->response() - ->data($entities) - ->success(); + return data($entities); } public function update(RestifyRequest $request, $repositoryId) @@ -732,9 +725,7 @@ public function update(RestifyRequest $request, $repositoryId) ->authorizedUpdate($request) ->each(fn (Field $field) => $field->actionHandler->handle($request, $this->resource)); - return $this->response() - ->data($this->serializeForShow($request)) - ->success(); + return data($this->serializeForShow($request)); } public function patch(RestifyRequest $request, $repositoryId) @@ -765,9 +756,7 @@ public function patch(RestifyRequest $request, $repositoryId) fn (Field $field) => $field->invokeAfter($request, $this->resource) ); - return $this->response() - ->data($this->serializeForShow($request)) - ->success(); + return data($this->serializeForShow($request)); } public function updateBulk(RestifyRequest $request, $repositoryId, int $row) @@ -790,7 +779,7 @@ public function updateBulk(RestifyRequest $request, $repositoryId, int $row) static::updatedBulk($this->resource, $request); - return response()->json(); + return ok(); } public function deleteBulk(RestifyRequest $request, $repositoryId, int $row) @@ -832,9 +821,7 @@ public function attach(RestifyRequest $request, $repositoryId, Collection $pivot })->each->save(); }); - return $this->response() - ->created() - ->data($pivots); + return data($pivots, 201); } public function detach(RestifyRequest $request, $repositoryId, Collection $pivots) @@ -849,9 +836,7 @@ public function detach(RestifyRequest $request, $repositoryId, Collection $pivot ->map(fn ($pivot) => $eagerField->authorizeToDetach($request, $pivot) && $pivot->delete()); }); - return $this->response() - ->data($deleted) - ->deleted(); + return data($deleted, 204); } public function destroy(RestifyRequest $request, $repositoryId) @@ -862,7 +847,7 @@ public function destroy(RestifyRequest $request, $repositoryId) static::deleted($status, $request); - return $this->response()->deleted(); + return ok(code: 204); } public function allowToUpdate(RestifyRequest $request, $payload = null): self From ca1242cb5c3043da31bf47aa5f9c85e833e2b058 Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Fri, 8 Jul 2022 12:31:16 +0300 Subject: [PATCH 17/42] fix: List restify routes through artisan command (#471) * fix: List restify routes through artisan command * Fix styling * fix: wip * fix: tests Co-authored-by: binaryk --- routes/api.php | 59 ------ src/Bootstrap/RoutesBoot.php | 41 ++--- src/Bootstrap/RoutesDefinition.php | 170 ++++++++++++++++++ .../Concerns/InteractWithRepositories.php | 15 +- src/LaravelRestifyServiceProvider.php | 6 + src/Repositories/Concerns/Testing.php | 2 +- src/Repositories/RepositoryEvents.php | 4 - src/Repositories/WithRoutePrefix.php | 48 +---- src/Restify.php | 41 ++++- .../RepositoryCustomPrefixTest.php | 22 +-- 10 files changed, 248 insertions(+), 160 deletions(-) delete mode 100644 routes/api.php create mode 100644 src/Bootstrap/RoutesDefinition.php diff --git a/routes/api.php b/routes/api.php deleted file mode 100644 index 23e9af909..000000000 --- a/routes/api.php +++ /dev/null @@ -1,59 +0,0 @@ -withoutMiddleware( - Binaryk\LaravelRestify\Http\Middleware\RestifySanctumAuthenticate::class, -); - -// Filters -Route::get('/{repository}/filters', \Binaryk\LaravelRestify\Http\Controllers\RepositoryFilterController::class); - -// Actions -Route::get('/{repository}/actions', \Binaryk\LaravelRestify\Http\Controllers\ListActionsController::class)->name('restify.actions.index'); -Route::get('/{repository}/{repositoryId}/actions', \Binaryk\LaravelRestify\Http\Controllers\ListRepositoryActionsController::class)->name('restify.actions.repository.index'); -Route::post('/{repository}/action', \Binaryk\LaravelRestify\Http\Controllers\PerformActionController::class)->name('restify.actions.perform'); -Route::post('/{repository}/actions', \Binaryk\LaravelRestify\Http\Controllers\PerformActionController::class); // alias to the previous route -Route::post('/{repository}/{repositoryId}/action', \Binaryk\LaravelRestify\Http\Controllers\PerformRepositoryActionController::class)->name('restify.actions.repository.perform'); -Route::post('/{repository}/{repositoryId}/actions', \Binaryk\LaravelRestify\Http\Controllers\PerformRepositoryActionController::class); // alias to the previous route - -// Getters -Route::get('/{repository}/getters', \Binaryk\LaravelRestify\Http\Controllers\ListGettersController::class)->name('restify.getters.index'); -Route::get('/{repository}/{repositoryId}/getters', \Binaryk\LaravelRestify\Http\Controllers\ListRepositoryGettersController::class)->name('restify.getters.repository.index'); -Route::get('/{repository}/getters/{getter}', \Binaryk\LaravelRestify\Http\Controllers\PerformGetterController::class)->name('restify.getters.perform'); -Route::get('/{repository}/{repositoryId}/getters/{getter}', \Binaryk\LaravelRestify\Http\Controllers\PerformRepositoryGetterController::class)->name('restify.getters.repository.perform'); - -// API CRUD -Route::get('/{repository}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryIndexController::class)->name('index'); -Route::post('/{repository}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreController::class)->name('restify.store'); -Route::post('/{repository}/bulk', \Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreBulkController::class)->name('restify.store.bulk'); -Route::post('/{repository}/bulk/update', \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateBulkController::class)->name('restify.update.bulk'); -Route::delete('/{repository}/bulk/delete', \Binaryk\LaravelRestify\Http\Controllers\RepositoryDestroyBulkController::class)->name('restify.destroy.bulk'); -Route::get('/{repository}/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryShowController::class)->name('restify.show'); -Route::patch('/{repository}/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryPatchController::class)->name('restify.patch'); -Route::put('/{repository}/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateController::class)->name('restify.put'); -Route::post('/{repository}/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateController::class)->name('restify.update'); -Route::delete('/{repository}/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryDestroyController::class)->name('restify.destroy'); - -// Fields -Route::delete('/{repository}/{repositoryId}/field/{field}', \Binaryk\LaravelRestify\Http\Controllers\FieldDestroyController::class); - -// Attach related repository id -Route::post('/{repository}/{repositoryId}/attach/{relatedRepository}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryAttachController::class); -Route::post('/{repository}/{repositoryId}/detach/{relatedRepository}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryDetachController::class); - -// Relatable -Route::get('/{parentRepository}/{parentRepositoryId}/{repository}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryIndexController::class); -Route::post('/{parentRepository}/{parentRepositoryId}/{repository}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreController::class); -Route::get('/{parentRepository}/{parentRepositoryId}/{repository}/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryShowController::class); -Route::post('/{parentRepository}/{parentRepositoryId}/{repository}/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateController::class); -Route::put('/{parentRepository}/{parentRepositoryId}/{repository}/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateController::class); -Route::delete('/{parentRepository}/{parentRepositoryId}/{repository}/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryDestroyController::class); diff --git a/src/Bootstrap/RoutesBoot.php b/src/Bootstrap/RoutesBoot.php index ac31a4387..f6144cda1 100644 --- a/src/Bootstrap/RoutesBoot.php +++ b/src/Bootstrap/RoutesBoot.php @@ -2,7 +2,7 @@ namespace Binaryk\LaravelRestify\Bootstrap; -use Binaryk\LaravelRestify\Http\Controllers\RepositoryIndexController; +use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; use Illuminate\Support\Facades\Route; @@ -10,6 +10,8 @@ class RoutesBoot { public function boot(): void { + Restify::ensureRepositoriesLoaded(); + $config = [ 'namespace' => null, 'as' => 'restify.api.', @@ -18,15 +20,15 @@ public function boot(): void ]; $this - ->defaultRoutes($config) ->registerPrefixed($config) - ->registerIndexPrefixed($config); + ->defaultRoutes($config); } public function defaultRoutes($config): self { Route::group($config, function () { - $this->loadRoutesFrom(__DIR__.'/../../routes/api.php'); + app(RoutesDefinition::class)->once(); + app(RoutesDefinition::class)(); }); return $this; @@ -35,35 +37,18 @@ public function defaultRoutes($config): self public function registerPrefixed($config): self { collect(Restify::$repositories) - ->filter(fn ($repository) => $repository::prefix()) + /** * @var Repository $repository */ ->each(function (string $repository) use ($config) { - $config['prefix'] = $repository::prefix(); - Route::group($config, function () { - $this->loadRoutesFrom(__DIR__.'/../../routes/api.php'); - }); - }); + if (! $repository::prefix()) { + return; + } - return $this; - } - - public function registerIndexPrefixed($config): self - { - collect(Restify::$repositories) - ->filter(fn ($repository) => $repository::hasIndexPrefix()) - ->each(function ($repository) use ($config) { - $config['prefix'] = $repository::indexPrefix(); - Route::group($config, function () { - Route::get('/{repository}', '\\'.RepositoryIndexController::class); + $config['prefix'] = $repository::prefix(); + Route::group($config, function () use ($repository) { + app(RoutesDefinition::class)($repository::uriKey()); }); }); return $this; } - - private function loadRoutesFrom(string $path): self - { - require $path; - - return $this; - } } diff --git a/src/Bootstrap/RoutesDefinition.php b/src/Bootstrap/RoutesDefinition.php new file mode 100644 index 000000000..a3f6ea56a --- /dev/null +++ b/src/Bootstrap/RoutesDefinition.php @@ -0,0 +1,170 @@ +name('restify.actions.index'); + Route::get( + $prefix.'/{repositoryId}/actions', + \Binaryk\LaravelRestify\Http\Controllers\ListRepositoryActionsController::class + )->name('restify.actions.repository.index'); + Route::post( + $prefix.'/action', + \Binaryk\LaravelRestify\Http\Controllers\PerformActionController::class + )->name('restify.actions.perform'); + Route::post( + $prefix.'/actions', + \Binaryk\LaravelRestify\Http\Controllers\PerformActionController::class + ); // alias to the previous route + Route::post( + $prefix.'/{repositoryId}/action', + \Binaryk\LaravelRestify\Http\Controllers\PerformRepositoryActionController::class + )->name('restify.actions.repository.perform'); + Route::post( + $prefix.'/{repositoryId}/actions', + \Binaryk\LaravelRestify\Http\Controllers\PerformRepositoryActionController::class + ); // alias to the previous route + + // Getters + Route::get( + $prefix.'/getters', + \Binaryk\LaravelRestify\Http\Controllers\ListGettersController::class + )->name('restify.getters.index'); + Route::get( + $prefix.'/{repositoryId}/getters', + \Binaryk\LaravelRestify\Http\Controllers\ListRepositoryGettersController::class + )->name('restify.getters.repository.index'); + Route::get( + $prefix.'/getters/{getter}', + \Binaryk\LaravelRestify\Http\Controllers\PerformGetterController::class + )->name('restify.getters.perform'); + Route::get( + $prefix.'/{repositoryId}/getters/{getter}', + \Binaryk\LaravelRestify\Http\Controllers\PerformRepositoryGetterController::class + )->name('restify.getters.repository.perform'); + + // API CRUD + Route::get( + $prefix.'', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryIndexController::class + )->name('index'); + Route::post( + $prefix.'', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreController::class + )->name('restify.store'); + Route::post( + $prefix.'/bulk', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreBulkController::class + )->name('restify.store.bulk'); + Route::post( + $prefix.'/bulk/update', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateBulkController::class + )->name('restify.update.bulk'); + Route::delete( + $prefix.'/bulk/delete', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryDestroyBulkController::class + )->name('restify.destroy.bulk'); + Route::get( + $prefix.'/{repositoryId}', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryShowController::class + )->name('restify.show'); + Route::patch( + $prefix.'/{repositoryId}', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryPatchController::class + )->name('restify.patch'); + Route::put( + $prefix.'/{repositoryId}', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateController::class + )->name('restify.put'); + Route::post( + $prefix.'/{repositoryId}', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateController::class + )->name('restify.update'); + Route::delete( + $prefix.'/{repositoryId}', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryDestroyController::class + )->name('restify.destroy'); + + if ($uriKey) { + return; + } + + // Fields + Route::delete( + $prefix.'/{repositoryId}/field/{field}', + \Binaryk\LaravelRestify\Http\Controllers\FieldDestroyController::class + ); + + // Attach related repository id + Route::post( + $prefix.'/{repositoryId}/attach/{relatedRepository}', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryAttachController::class + ); + Route::post( + $prefix.'/{repositoryId}/detach/{relatedRepository}', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryDetachController::class + ); + + // Relatable + Route::get( + '/{parentRepository}/{parentRepositoryId}/{repository}', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryIndexController::class + ); + Route::post( + '/{parentRepository}/{parentRepositoryId}/{repository}', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreController::class + ); + Route::get( + '/{parentRepository}/{parentRepositoryId}/{repository}/{repositoryId}', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryShowController::class + ); + Route::post( + '/{parentRepository}/{parentRepositoryId}/{repository}/{repositoryId}', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateController::class + ); + Route::put( + '/{parentRepository}/{parentRepositoryId}/{repository}/{repositoryId}', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateController::class + ); + Route::delete( + '/{parentRepository}/{parentRepositoryId}/{repository}/{repositoryId}', + \Binaryk\LaravelRestify\Http\Controllers\RepositoryDestroyController::class + ); + } + + public function once(): void + { + Route::get('/search', GlobalSearchController::class); + + Route::get('/profile', ProfileController::class); + Route::put('/profile', ProfileUpdateController::class); + Route::post('/profile', ProfileUpdateController::class); + + // RestifyJS + Route::get('/restifyjs/setup', RestifyJsSetupController::class)->withoutMiddleware( + RestifySanctumAuthenticate::class, + ); + } +} diff --git a/src/Http/Requests/Concerns/InteractWithRepositories.php b/src/Http/Requests/Concerns/InteractWithRepositories.php index c474e7316..0e33ed1d9 100644 --- a/src/Http/Requests/Concerns/InteractWithRepositories.php +++ b/src/Http/Requests/Concerns/InteractWithRepositories.php @@ -25,6 +25,13 @@ public function repository($key = null): Repository try { $key = $key ?? $this->route('repository'); + /** + * @var Repository|null $class + */ + if (is_null($key) && $class = Restify::repositoryClassForPrefix($this->getRequestUri())) { + $key = $class::uriKey(); + } + throw_if(is_null($key), RepositoryException::missingKey()); $repository = Restify::repository($key); @@ -100,11 +107,9 @@ public function isViaRepository(): bool $parentRepositoryId = $this->route('parentRepositoryId'); //TODO: Find another implementation for prefixes: - $matchSomePrefixes = collect(Restify::$repositories) - ->some(fn ($repository) => $repository::prefix() === "$parentRepository/$parentRepositoryId") - || collect(Restify::$repositories)->some(fn ( - $repository - ) => $repository::indexPrefix() === "$parentRepository/$parentRepositoryId"); + $matchSomePrefixes = collect(Restify::$repositories)->some(fn ( + $repository + ) => $repository::prefix() === "$parentRepository/$parentRepositoryId"); if ($matchSomePrefixes) { return false; diff --git a/src/LaravelRestifyServiceProvider.php b/src/LaravelRestifyServiceProvider.php index afc9a5d83..7a3c67c9d 100644 --- a/src/LaravelRestifyServiceProvider.php +++ b/src/LaravelRestifyServiceProvider.php @@ -2,6 +2,7 @@ namespace Binaryk\LaravelRestify; +use Binaryk\LaravelRestify\Bootstrap\RoutesBoot; use Binaryk\LaravelRestify\Commands\ActionCommand; use Binaryk\LaravelRestify\Commands\BaseRepositoryCommand; use Binaryk\LaravelRestify\Commands\DevCommand; @@ -18,6 +19,7 @@ use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Http\Kernel; +use Illuminate\Support\Facades\App; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -61,6 +63,10 @@ public function packageBooted(): void $kernel = $this->app->make(Kernel::class); $kernel->pushMiddleware(RestifyInjector::class); + + if (! App::runningUnitTests()) { + app(RoutesBoot::class)->boot(); + } } public function packageRegistered(): void diff --git a/src/Repositories/Concerns/Testing.php b/src/Repositories/Concerns/Testing.php index b13481158..b03238517 100644 --- a/src/Repositories/Concerns/Testing.php +++ b/src/Repositories/Concerns/Testing.php @@ -23,7 +23,7 @@ public static function route(string $path = null, array $query = []): Stringable $path = str($path)->replaceFirst('/', '')->toString(); } - $base = Str::replaceFirst('//', '/', Restify::path().'/'.static::uriKey()); + $base = (static::prefix() ?: Str::replaceFirst('//', '/', Restify::path())) .'/'.static::uriKey(); $route = $path ? $base.'/'.$path diff --git a/src/Repositories/RepositoryEvents.php b/src/Repositories/RepositoryEvents.php index 3d0601146..c6238742d 100644 --- a/src/Repositories/RepositoryEvents.php +++ b/src/Repositories/RepositoryEvents.php @@ -66,10 +66,6 @@ public static function mounting(): void if (static::$prefix) { static::setPrefix(static::$prefix, static::uriKey()); } - - if (static::$indexPrefix) { - static::setIndexPrefix(static::$indexPrefix, static::uriKey()); - } } /** diff --git a/src/Repositories/WithRoutePrefix.php b/src/Repositories/WithRoutePrefix.php index f12e246eb..dd07ca24f 100644 --- a/src/Repositories/WithRoutePrefix.php +++ b/src/Repositories/WithRoutePrefix.php @@ -14,12 +14,6 @@ trait WithRoutePrefix */ public static $prefix; - /** - * List of index prefixes by uriKey. - * @var array - */ - public static $indexPrefixes; - /** * The repository prefixes by key. * @@ -27,12 +21,6 @@ trait WithRoutePrefix */ private static $prefixes; - /** - * The repository index route default prefix. - * @var string - */ - public static $indexPrefix; - public static function prefix(): ?string { return static::hasPrefix() @@ -42,15 +30,6 @@ public static function prefix(): ?string : null; } - public static function indexPrefix(): ?string - { - return static::hasIndexPrefix() - ? static::sanitizeSlashes( - static::$indexPrefixes[static::uriKey()] - ) - : null; - } - /** * Determines whether a repository has prefix. * @@ -63,13 +42,6 @@ protected static function hasPrefix(): bool return isset(static::$prefixes[$name]) && ! empty(static::$prefixes[$name]); } - public static function hasIndexPrefix(): bool - { - $name = static::uriKey(); - - return isset(static::$indexPrefixes[$name]) && ! empty(static::$indexPrefixes[$name]); - } - protected static function sanitizeSlashes(?string $prefix): ?string { if ($prefix && Str::startsWith($prefix, '/')) { @@ -90,11 +62,6 @@ public static function authorizedToUseRoute(RestifyRequest $request): bool } if ($request->isIndexRequest()) { - // index - if (static::indexPrefix()) { - return $request->is(static::indexPrefix().'/*'); - } - if (static::prefix()) { return $request->is(static::prefix().'/*'); } @@ -108,21 +75,12 @@ protected static function shouldAuthorizeRouteUsage(): bool { return collect([ static::prefix(), - static::indexPrefix(), ])->some(fn ($prefix) => (bool) $prefix); } - public static function setPrefix(string $prefix, string $uriKey = null) - { - if ($prefix) { - static::$prefixes[$uriKey ?? static::uriKey()] = $prefix; - } - } - - public static function setIndexPrefix(string $prefix, string $uriKey = null) + public static function setPrefix(?string $prefix, string $uriKey = null): void { - if ($prefix) { - static::$indexPrefixes[$uriKey ?? static::uriKey()] = $prefix; - } + static::$prefixes[$uriKey ?? static::uriKey()] = $prefix; + static::$prefix = $prefix; } } diff --git a/src/Restify.php b/src/Restify.php index 0f58557c0..b7e7f5b4c 100644 --- a/src/Restify.php +++ b/src/Restify.php @@ -56,6 +56,24 @@ public static function repositoryClassForKey(string $key): ?string }); } + /** + * Get the repository class for the prefix. + * + * @param string $prefix + * @return string|null + */ + public static function repositoryClassForPrefix(string $prefix): ?string + { + return collect(static::$repositories)->first(function ($value) use ($prefix) { + /** * @var Repository $value */ + return + $value::route() + ->whenStartsWith('/', fn ($string) => $string->replaceFirst('/', ''))->is( + str($prefix)->whenStartsWith('/', fn ($string) => $string->replaceFirst('/', '')) + ); + }); + } + /** * Return the repository instance for a given key. * @@ -169,13 +187,13 @@ public static function path($plus = null, array $query = []) { if (! is_null($plus)) { return empty($query) - ? config('restify.base', '/restify-api').'/'.$plus - : config('restify.base', '/restify-api').'/'.$plus.'?'.http_build_query($query); + ? config('restify.base', '/restify-api').'/'.$plus + : config('restify.base', '/restify-api').'/'.$plus.'?'.http_build_query($query); } return empty($query) - ? config('restify.base', '/restify-api') - : config('restify.base', '/restify-api').'?'.http_build_query($query); + ? config('restify.base', '/restify-api') + : config('restify.base', '/restify-api').'?'.http_build_query($query); } /** @@ -259,9 +277,16 @@ public static function isRestify(Request $request): bool $request->is('restify-api/*') || collect(static::$repositories) ->filter(fn ($repository) => $repository::prefix()) - ->some(fn ($repository) => $request->is($repository::prefix().'/*')) || - collect(static::$repositories) - ->filter(fn ($repository) => $repository::indexPrefix()) - ->some(fn ($repository) => $request->is($repository::indexPrefix().'/*')); + ->some(fn ($repository) => $request->is($repository::prefix().'/*')); + } + + /** + * @throws ReflectionException + */ + public static function ensureRepositoriesLoaded(): void + { + if (empty(static::$repositories)) { + static::repositoriesFrom(app_path('Restify')); + } } } diff --git a/tests/Repositories/RepositoryCustomPrefixTest.php b/tests/Repositories/RepositoryCustomPrefixTest.php index 2b203cfeb..e00203318 100644 --- a/tests/Repositories/RepositoryCustomPrefixTest.php +++ b/tests/Repositories/RepositoryCustomPrefixTest.php @@ -2,6 +2,7 @@ namespace Binaryk\LaravelRestify\Tests\Repositories; +use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; use Binaryk\LaravelRestify\Tests\IntegrationTest; @@ -11,8 +12,6 @@ protected function setUp(): void { PostRepository::setPrefix('api/v1'); - PostRepository::setIndexPrefix('api/index'); - parent::setUp(); } @@ -20,26 +19,29 @@ protected function tearDown(): void { parent::tearDown(); - PostRepository::$prefix = null; - PostRepository::$indexPrefix = null; + PostRepository::setPrefix(null); } public function test_repository_can_have_custom_prefix(): void { $this - ->getJson('api/index/'.PostRepository::uriKey()) + ->withoutExceptionHandling() + ->getJson(PostRepository::route()) ->assertSuccessful(); } public function test_repository_prefix_block_default_route(): void { - $this->getJson(PostRepository::route()) + $this->getJson(Restify::path(PostRepository::uriKey())) ->assertForbidden(); - $this->getJson('api/index/'.PostRepository::uriKey()) - ->assertSuccessful(); - - $this->postJson(PostRepository::route()) + $this->postJson(Restify::path(PostRepository::uriKey())) ->assertForbidden(); + + $this->getJson(PostRepository::route()) + ->assertOk(); + + $this->postJson(PostRepository::route(), ['title' => 'Title', 'user_id' => 1]) + ->assertCreated(); } } From f03536cd1693096b92880fbc78cf5590ab033023 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Fri, 8 Jul 2022 13:54:59 +0300 Subject: [PATCH 18/42] fix: wip --- tests/Fields/HasManyTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Fields/HasManyTest.php b/tests/Fields/HasManyTest.php index 18aed126a..6fa88a982 100644 --- a/tests/Fields/HasManyTest.php +++ b/tests/Fields/HasManyTest.php @@ -327,7 +327,7 @@ class UserWithPosts extends Repository public static function include(): array { return [ - 'posts' => HasMany::make('posts', PostRepository::class), + HasMany::make('posts', PostRepository::class), ]; } From cb995b015830822cba0441b1e2ac24df4998b2ac Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Fri, 8 Jul 2022 14:08:21 +0300 Subject: [PATCH 19/42] fix: helpers for action logs --- src/Models/ActionLog.php | 19 ++++++++++++++++++- tests/Feature/ActionLogTest.php | 22 ++++++++++++++++------ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/Models/ActionLog.php b/src/Models/ActionLog.php index 59736e47c..924702d6e 100644 --- a/src/Models/ActionLog.php +++ b/src/Models/ActionLog.php @@ -152,7 +152,9 @@ public static function register( public function withMeta(string $key, mixed $value): self { - $this->meta[$key] = $value; + $this->meta = array_merge($this->meta ?: [], [ + $key => $value, + ]); return $this; } @@ -163,4 +165,19 @@ public function withMetas(array $metas): self return $this; } + + public function actor(Model $model): self + { + $this->user_id = $model->getKey(); + + return $this; + } + + public function target(Model $model): self + { + $this->target_id = $model->getKey(); + $this->target_type = $model->getMorphClass(); + + return $this; + } } diff --git a/tests/Feature/ActionLogTest.php b/tests/Feature/ActionLogTest.php index 425bfad6f..03536e292 100644 --- a/tests/Feature/ActionLogTest.php +++ b/tests/Feature/ActionLogTest.php @@ -99,7 +99,7 @@ public function test_can_create_log_for_repository_deleting() $this->assertDatabaseCount('action_logs', 1); } - public function test_can_create_log_for_repository_custom_action() + public function test_can_create_log_for_repository_custom_action(): void { $this->authenticate(); @@ -134,7 +134,7 @@ public function test_store_log_on_store_request(): void ->posts() ->attributes(['title' => 'Title', 'user_id' => 1]) ->create( - fn (AssertablePost $assertablePost) => $assertablePost + fn(AssertablePost $assertablePost) => $assertablePost ->hasActionLog() ->etc() )->model(); @@ -160,7 +160,7 @@ public function test_store_log_on_update_request(): void ->create() ->attributes(['title' => 'Updated post']) ->update( - assertable: fn (AssertablePost $assertablePost) => $assertablePost + assertable: fn(AssertablePost $assertablePost) => $assertablePost ->hasActionLog(2) ->etc() )->model(); @@ -193,7 +193,7 @@ public function test_store_log_on_destroy_request(): void ->attributes(['title' => 'Updated post']) ->destroy( key: $post->getKey(), - tap: fn (TestResponse $assertablePost) => $assertablePost + tap: fn(TestResponse $assertablePost) => $assertablePost ->assertNoContent() ); @@ -271,12 +271,22 @@ public function test_can_store_custom_logs(): void { $post = PostFactory::one(); - ActionLog::register('Activated post', $post, [], $this->authenticatedAs)->save(); + $log = ActionLog::register('Activated post', $post, []) + ->actor($this->authenticatedAs) + ->target($post) + ->withMeta('foo', 'bar'); + + $log->save(); + + AssertableActionLog::make($log->fresh()) + ->where('target_type', $post->getMorphClass()) + ->where('meta.foo', 'bar'); $this->assertDatabaseHas('action_logs', [ 'name' => 'Activated post', 'actionable_type' => $post::class, 'actionable_id' => $post->getKey(), + 'user_id' => $this->authenticatedAs->getKey(), ]); } @@ -288,7 +298,7 @@ public function test_store_all_logs_when_enabled_and_go_through_restify_and_muta ->create() ->attributes(['title' => 'Updated post']) ->update( - assertable: fn (AssertablePost $assertablePost) => $assertablePost + assertable: fn(AssertablePost $assertablePost) => $assertablePost ->hasActionLog(2) ->etc() ) From 9a73750a78eddbbc4c7a3d39460466a7d47e8d3d Mon Sep 17 00:00:00 2001 From: binaryk Date: Fri, 8 Jul 2022 11:08:49 +0000 Subject: [PATCH 20/42] Fix styling --- tests/Feature/ActionLogTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Feature/ActionLogTest.php b/tests/Feature/ActionLogTest.php index 03536e292..b26ace009 100644 --- a/tests/Feature/ActionLogTest.php +++ b/tests/Feature/ActionLogTest.php @@ -134,7 +134,7 @@ public function test_store_log_on_store_request(): void ->posts() ->attributes(['title' => 'Title', 'user_id' => 1]) ->create( - fn(AssertablePost $assertablePost) => $assertablePost + fn (AssertablePost $assertablePost) => $assertablePost ->hasActionLog() ->etc() )->model(); @@ -160,7 +160,7 @@ public function test_store_log_on_update_request(): void ->create() ->attributes(['title' => 'Updated post']) ->update( - assertable: fn(AssertablePost $assertablePost) => $assertablePost + assertable: fn (AssertablePost $assertablePost) => $assertablePost ->hasActionLog(2) ->etc() )->model(); @@ -193,7 +193,7 @@ public function test_store_log_on_destroy_request(): void ->attributes(['title' => 'Updated post']) ->destroy( key: $post->getKey(), - tap: fn(TestResponse $assertablePost) => $assertablePost + tap: fn (TestResponse $assertablePost) => $assertablePost ->assertNoContent() ); @@ -298,7 +298,7 @@ public function test_store_all_logs_when_enabled_and_go_through_restify_and_muta ->create() ->attributes(['title' => 'Updated post']) ->update( - assertable: fn(AssertablePost $assertablePost) => $assertablePost + assertable: fn (AssertablePost $assertablePost) => $assertablePost ->hasActionLog(2) ->etc() ) From 7d7c8180b88246334bbc384b3b64b7379c3432e5 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Fri, 8 Jul 2022 15:45:02 +0300 Subject: [PATCH 21/42] fix: fix eager fields without key in include --- src/Eager/RelatedCollection.php | 3 ++- src/Http/Requests/RestifyRequest.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Eager/RelatedCollection.php b/src/Eager/RelatedCollection.php index 148ec37d2..22a906d32 100644 --- a/src/Eager/RelatedCollection.php +++ b/src/Eager/RelatedCollection.php @@ -34,7 +34,8 @@ public function intoAssoc(): self public function forEager(RestifyRequest $request): self { - return $this->filter(fn ($value, $key) => $value instanceof EagerField) + return $this + ->filter(fn ($value, $key) => $value instanceof EagerField) ->filter(fn (Field $field) => $field->authorize($request)) ->unique('attribute'); } diff --git a/src/Http/Requests/RestifyRequest.php b/src/Http/Requests/RestifyRequest.php index 710a1eaac..2f019f739 100644 --- a/src/Http/Requests/RestifyRequest.php +++ b/src/Http/Requests/RestifyRequest.php @@ -34,7 +34,7 @@ public function relatedEagerField(): EagerField /** * @var EagerField $eagerField */ $eagerField = $parentRepository::collectRelated() ->forEager($this) - ->first(fn ($field, $key) => $key === $this->route('repository')); + ->first(fn (EagerField $field, $key) => $field->getAttribute() === $this->route('repository')); if (is_null($eagerField)) { abort(403, 'Eager field missing from the parent ['.$this->route('parentRepository').'] related fields.'); From 15816cbbdbefdd64d17a5abde05ecc039f67d949 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Fri, 8 Jul 2022 15:46:20 +0300 Subject: [PATCH 22/42] fix: wip --- UPGRADING.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index 5ea00bbbc..227895e90 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -2,13 +2,18 @@ ## From 6.x to 7.x +High impact: + +- Any action permitted unless the Model Policy exists and the method is defined - PHP8.1 is required - Laravel 9 is required -- Restify.php - `repositoryForKey` renamed to `repositoryClassForKey` -- Any action permitted unless the Model Policy exists and the method is defined - Repository.php: - - static `to` method renamed to `route` - - `related` static method deleted, replace with `include` + - static `to` method renamed to `route` + - `related` static method deleted, replace with `include` + +Low impact: + +- Restify.php - `repositoryForKey` renamed to `repositoryClassForKey` ## From 6.2.1 to 6.3.0 From 0c14704f848b8e8c390dca19d8cfc99ab0f47744 Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Tue, 12 Jul 2022 11:12:49 +0300 Subject: [PATCH 23/42] fix: custom serializer (#472) * fix: custom serializer * Fix styling * fix: serializer docs Co-authored-by: binaryk --- ROADMAP.md | 16 +- config/restify.php | 2 +- docs-v2/content/en/api/rest-methods.md | 7 +- docs-v2/content/en/api/serializer.md | 108 ++++++++++++ docs-v2/content/en/auth/authorization.md | 88 ++-------- src/Commands/stubs/policy.stub | 16 +- src/Repositories/Repository.php | 7 +- src/Repositories/Serializer.php | 159 ++++++++++++++++++ src/Traits/InteractWithSearch.php | 7 +- src/helpers.php | 14 ++ tests/Fixtures/Post/PostPolicy.php | 2 +- tests/IntegrationTest.php | 7 + .../Repositories/RepositorySerializerTest.php | 41 +++++ 13 files changed, 376 insertions(+), 98 deletions(-) create mode 100644 docs-v2/content/en/api/serializer.md create mode 100644 src/Repositories/Serializer.php create mode 100644 tests/Repositories/RepositorySerializerTest.php diff --git a/ROADMAP.md b/ROADMAP.md index 4ffe33481..3f5e6e22a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,20 +4,20 @@ ### Fixes -- [ ] Clean up controllers -- [ ] Reduce the main Repository class by using traits +- [x] Clean up controllers +- [x] Reduce the main Repository class by using traits - [ ] Request validations should be rewritten - [ ] Revisit the `InteractWithRepositories` trait and clean model queries accordingly -- [ ] Adding support for PHPStan and configure the level 4 - [x] Clean up all tests using AssertableJson [x] - [x] Make sure the `include` matches array key firstly, and secondly the relationship name ### Features - [x] Adding support for custom ActionLogs (ie ActionLog::register("project marked active by user Auth::id()", $project->id)) -- [ ] Ensure `$with` loads relationship in `show` requests -- [ ] Make sure any action isn't permitted unless the Model Policy exists -- [ ] Ability to make an endpoint public using a policy method +- [x] Ensure `$with` loads relationship in `show` requests +- [x] Make sure any action isn't permitted unless the Model Policy exists +- [x] Having a helper method that allow to return data using the repository from a custom controller `PostRepository::withModels(Post::query()->take(5)->get())->include('user')->serializeForShow()` +- [ ] Serialize nested relationships 8.x @@ -26,9 +26,9 @@ - [ ] Adding Larastan support - [ ] Drop Psalm - [ ] Adding PestPHP support +- [ ] Adding support for PHPStan and configure the level 4 ### Features -- [ ] Serialize nested relationships -- [ ] Having a helper method that allow to return data using the repository from a custom controller `PostRepository::withModels(Post::query()->take(5)->get())->include('user')->serializeForShow()` - [ ] Adding a command that lists all Restify registered routes `php artisan restify:routes` +- [ ] Ability to make an endpoint public using a policy method diff --git a/config/restify.php b/config/restify.php index e4de6f08f..0cb6c081d 100644 --- a/config/restify.php +++ b/config/restify.php @@ -95,7 +95,7 @@ 'middleware' => [ 'api', - //auth.sanctum, +// 'auth.sanctum', DispatchRestifyStartingEvent::class, AuthorizeRestify::class, ], diff --git a/docs-v2/content/en/api/rest-methods.md b/docs-v2/content/en/api/rest-methods.md index a0e9c42b1..ff74cd584 100644 --- a/docs-v2/content/en/api/rest-methods.md +++ b/docs-v2/content/en/api/rest-methods.md @@ -1,9 +1,4 @@ ---- -title: REST Methods -menuTitle: Controllers -category: API -position: 12 ---- +--- title: REST Methods menuTitle: Controllers category: API position: 12 --- ## Introduction diff --git a/docs-v2/content/en/api/serializer.md b/docs-v2/content/en/api/serializer.md new file mode 100644 index 000000000..0d8501888 --- /dev/null +++ b/docs-v2/content/en/api/serializer.md @@ -0,0 +1,108 @@ +--- +title: Serializer +menuTitle: Serializer +category: API +position: 12 +--- + +## Introduction + +The API response format must stay consistent along the application. Ideally it would be good to follow a standard as +the [JSON:API](https://jsonapi.org/format/) so your frontend app could align with the API. + +Restify provides a convenient way to quickly return a response in a consistent format. + + +## rest + +```php +return rest(Company::first()) + ->related('users') + ->sortDesc('id'); +``` + +The `rest` helper accept a list of models and returns a `\Binaryk\LaravelRestify\Repositories\Serializer` instance, so you can call its fluent API. + +The `Serializer` will look for the repository associated with your models. If there is a repository associated with your Company (ie CompanyRepository), Serializer will use that repository to serialize your models accordingly: + +```json +{ + "data": { + "id": "1", + "type": "companies", + "attributes": { + "name": "BinarCode" + }, + "relationships": { + "users": [ + { + "id": "1", + "type": "users", + "attributes": { + "name": "Eduard", + "email": "eduard.lupacescu@binarcode.com" + }, + "meta": { + "authorizedToShow": true, + "authorizedToStore": true, + "authorizedToUpdate": true, + "authorizedToDelete": true + }, + "pivots": { + "is_admin": true + } + } + ] + }, + "meta": { + "authorizedToShow": true, + "authorizedToStore": true, + "authorizedToUpdate": true, + "authorizedToDelete": true + } + } +} +``` + +In case there isn't a repository associated with your models, the response will simply be a data object with models. + +The `rest` helper accept a model as well as a list (collection) of models, and it'll serialize the response accordingly: + +```php +rest(Post::all()) + ->related('user') + ->sortDesc('id') + ->perPage(20) +``` + +## data + +```php +data(User::first(), 200) +``` + +This helper simply wrap provided data into an object with `data` key: + +```json +{ + "data": { + "id": 1, + "name": "User name", + "email": "kshlerin.hertha@example.com" + } +} +``` + +### ok + +```php +ok('All good!') +``` + +`ok` helper accepts an optional message as argument, so you can return a successful response with a custom message. + +```json +{ + "message": "All good!" +} +``` diff --git a/docs-v2/content/en/auth/authorization.md b/docs-v2/content/en/auth/authorization.md index a30c311fb..cc3316d05 100644 --- a/docs-v2/content/en/auth/authorization.md +++ b/docs-v2/content/en/auth/authorization.md @@ -78,13 +78,17 @@ php artisan restify:policy UserPolicy It will automatically detect the `User` model (the word before `Policy`). However, you can specify the model: ```shell script -php artisan restify:policy PostPolicy --model=User +php artisan restify:policy PostPolicy --model=Post ``` It will consider that the model lives into the `app/Models` directory. + +By default, Restify will unauthorized any requests if there isn't a defined policy method associated to the request endpoint. Or, if you don't have a policy at all, all requests to that repository will be unauthorized. + + If you already have a policy, here is the Restify default scaffolded one, so you can take methods on your own: ```php @@ -98,115 +102,55 @@ class PostPolicy { use HandlesAuthorization; - /** - * Determine whether the user can use restify feature for each CRUD operation. - * So if this is not allowed, all operations will be disabled - * @param User $user - * @return mixed - */ - public function allowRestify(User $user = null) + public function allowRestify(User $user = null): bool { // } - /** - * Determine whether the user can get the model. - * - * @param User $user - * @param Post $model - * @return mixed - */ - public function show(User $user, Post $model) + public function show(User $user, Post $model): bool { // } - /** - * Determine whether the user can create models. - * - * @param User $user - * @return mixed - */ - public function store(User $user) + public function store(User $user): bool { // } - /** - * Determine whether the user can create multiple models at once. - * - * @param User $user - * @return mixed - */ - public function storeBulk(User $user) + public function storeBulk(User $user): bool { // } - /** - * Determine whether the user can update the model. - * - * @param User $user - * @param Post $model - * @return mixed - */ - public function update(User $user, Post $model) + public function update(User $user, Post $model): bool { // } - /** - * Determine whether the user can update bulk the model. - * - * @param User $user - * @param Post $model - * @return mixed - */ - public function updateBulk(User $user, Post $model) + public function updateBulk(User $user, Post $model): bool { // } - /** - * Determine whether the user can delete the model. - * - * @param User $user - * @param Post $model - * @return mixed - */ - public function delete(User $user, Post $model) + public function delete(User $user, Post $model): bool { // } - /** - * Determine whether the user can restore the model. - * - * @param User $user - * @param Post $model - * @return mixed - */ - public function restore(User $user, Post $model) + public function restore(User $user, Post $model): bool { // } - /** - * Determine whether the user can permanently delete the model. - * - * @param User $user - * @param Post $model - * @return mixed - */ - public function forceDelete(User $user, Post $model) + public function forceDelete(User $user, Post $model): bool { // } } ``` - -For the examples bellow, we will consider `PostRepository` as being an example. + +For the examples bellow, we will consider PostRepository as being an example. ### Allow restify diff --git a/src/Commands/stubs/policy.stub b/src/Commands/stubs/policy.stub index 98788d541..2de819bd5 100644 --- a/src/Commands/stubs/policy.stub +++ b/src/Commands/stubs/policy.stub @@ -10,42 +10,42 @@ class {{ class }} { use HandlesAuthorization; - public function allowRestify(User $user = null) + public function allowRestify(User $user = null): bool { // } - public function show(User $user, {{ model }} $model) + public function show(User $user, {{ model }} $model): bool { // } - public function store(User $user) + public function store(User $user): bool { // } - public function storeBulk(User $user) + public function storeBulk(User $user): bool { // } - public function update(User $user, {{ model }} $model) + public function update(User $user, {{ model }} $model): bool { // } - public function updateBulk(User $user, {{ model }} $model) + public function updateBulk(User $user, {{ model }} $model): bool { // } - public function deleteBulk(User $user, {{ model }} $model) + public function deleteBulk(User $user, {{ model }} $model): bool { // } - public function delete(User $user, {{ model }} $model) + public function delete(User $user, {{ model }} $model): bool { // } diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 83de3ebba..ab5a19735 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -40,7 +40,7 @@ /** * @property string $type */ -abstract class Repository implements RestifySearchable, JsonSerializable +class Repository implements RestifySearchable, JsonSerializable { use InteractWithSearch; use ValidatingTrait; @@ -1105,4 +1105,9 @@ public static function usesScout(): bool { return in_array("Laravel\Scout\Searchable", class_uses_recursive(static::newModel())); } + + public static function serializer(): Serializer + { + return (new Serializer(app(static::class))); + } } diff --git a/src/Repositories/Serializer.php b/src/Repositories/Serializer.php new file mode 100644 index 000000000..f8582bce0 --- /dev/null +++ b/src/Repositories/Serializer.php @@ -0,0 +1,159 @@ +perPage = ($this->repository)::$defaultPerPage; + } + + public function repository(Repository $class): self + { + $this->repository = $class; + + return $this; + } + + public function related(...$related): self + { + $this->related = collect(Arr::wrap($related))->flatten()->all(); + + return $this; + } + + public function sort(SortableFilter $sort): self + { + $this->sort = $sort; + + return $this; + } + + public function sortAsc(string $column): self + { + return $this->sort(SortableFilter::make()->setColumn($column)->asc()); + } + + public function sortDesc(string $column): self + { + return $this->sort(SortableFilter::make()->setColumn($column)->desc()); + } + + public function perPage(int $perPage): self + { + $this->perPage = $perPage; + + return $this; + } + + public function indexMeta(array $meta): self + { + $this->meta = $meta; + + return $this; + } + + public function model(Model $model): self + { + $this->repository = $this->repository::resolveWith($model); + + return $this; + } + + public function models(Collection $models): self + { + if ($models->count() === 1) { + return $this->model($models->first()); + } + + $this->items = $models + ->filter(fn($model) => $model instanceof Model) + ->map(fn(Model $value) => $this->repository::resolveWith($value)); + + return $this; + } + + public function jsonSerialize(): mixed + { + if (is_null($this->items)) { + return []; + } + + if ($this->items->count() === 1) { + return $this->repository->serializeForShow( + $this->request(RepositoryShowRequest::class) + ); + } + + $paginator = new Paginator($this->items->values(), $this->perPage); + + if (!$this->hasCustomRepository()) { + return ['data' => $paginator->getCollection()]; + } + + $request = $this->request(RepositoryIndexRequest::class); + $items = $paginator->getCollection(); + + return $this->filter([ + 'meta' => $this->meta ?: RepositoryCollection::meta($paginator->toArray()), + 'links' => array_merge(RepositoryCollection::paginationLinks($paginator->toArray()), [ + 'filters' => Restify::path($this->repository::uriKey().'/filters'), + ]), + 'data' => $items + ->when($this->sort && $this->sort->direction() === 'desc', + fn(Collection $items) => $items->sortByDesc($this->sort->column())) + ->when($this->sort && $this->sort->direction() === 'asc', + fn(Collection $items) => $items->sortBy($this->sort->column())) + ->map(fn(Repository $repository) => $repository->serializeForIndex($request)), + ]); + } + + private function request(string $class = null): RestifyRequest + { + /** + * @var RestifyRequest $request + */ + $request = app($class ?? RestifyRequest::class); + + $request->merge([ + 'related' => implode(',', $this->related), + ]); + + return $request; + } + + public function toResponse($request) + { + return $this; + } + + private function hasCustomRepository(): bool + { + return get_class($this->repository) !== Repository::class; + } +} diff --git a/src/Traits/InteractWithSearch.php b/src/Traits/InteractWithSearch.php index 0263938ed..b0931a472 100644 --- a/src/Traits/InteractWithSearch.php +++ b/src/Traits/InteractWithSearch.php @@ -34,11 +34,16 @@ public static function withs(): array return static::$withs ?? []; } - public static function include(): array + public static function related(): array { return static::$related ?? []; } + public static function include(): array + { + return static::related(); + } + public static function collectRelated(): RelatedCollection { return RelatedCollection::make(static::include()); diff --git a/src/helpers.php b/src/helpers.php index 61552cc5d..fc6b87d2e 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -1,6 +1,8 @@ readonly(); } } + +if (! function_exists('rest')) { + function rest(...$models): Serializer + { + $models = collect($models)->flatten(); + + $repository = Restify::repositoryForModel(get_class($models->first())) ?? Repository::class; + + return (new Serializer(app($repository))) + ->models(collect($models)); + } +} diff --git a/tests/Fixtures/Post/PostPolicy.php b/tests/Fixtures/Post/PostPolicy.php index 889f5f745..61fa7951f 100644 --- a/tests/Fixtures/Post/PostPolicy.php +++ b/tests/Fixtures/Post/PostPolicy.php @@ -4,7 +4,7 @@ class PostPolicy { - public function allowRestify() + public function allowRestify($user = null) { return $_SERVER['restify.post.allowRestify'] ?? true; } diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 404698d75..2f7c6bc8f 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -159,4 +159,11 @@ protected function ensureLoggedIn(): self return $this; } + + protected function logout(): self + { + $this->actingAs(null); + + return $this; + } } diff --git a/tests/Repositories/RepositorySerializerTest.php b/tests/Repositories/RepositorySerializerTest.php new file mode 100644 index 000000000..0145bfa60 --- /dev/null +++ b/tests/Repositories/RepositorySerializerTest.php @@ -0,0 +1,41 @@ + 'Title', + ]); + + PostRepository::partialMock() + ->shouldReceive('include') + ->andReturn([ + 'user' => BelongsTo::make('user', UserRepository::class), + ]); + + $response = rest(Post::all()) + ->related('user') + ->sortDesc('id') + ->perPage(20) + ->jsonSerialize(); + + $assertable = AssertableJson::fromArray($response); + + $assertable + ->has('meta') + ->has('data') + ->count('data', 20) + ->etc(); + } +} From a3f0fc488f0b9949eab1c1b613fefb108be1987f Mon Sep 17 00:00:00 2001 From: binaryk Date: Tue, 12 Jul 2022 08:13:19 +0000 Subject: [PATCH 24/42] Fix styling --- src/Repositories/Serializer.php | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Repositories/Serializer.php b/src/Repositories/Serializer.php index f8582bce0..da5f7bb4d 100644 --- a/src/Repositories/Serializer.php +++ b/src/Repositories/Serializer.php @@ -92,8 +92,8 @@ public function models(Collection $models): self } $this->items = $models - ->filter(fn($model) => $model instanceof Model) - ->map(fn(Model $value) => $this->repository::resolveWith($value)); + ->filter(fn ($model) => $model instanceof Model) + ->map(fn (Model $value) => $this->repository::resolveWith($value)); return $this; } @@ -112,7 +112,7 @@ public function jsonSerialize(): mixed $paginator = new Paginator($this->items->values(), $this->perPage); - if (!$this->hasCustomRepository()) { + if (! $this->hasCustomRepository()) { return ['data' => $paginator->getCollection()]; } @@ -125,11 +125,15 @@ public function jsonSerialize(): mixed 'filters' => Restify::path($this->repository::uriKey().'/filters'), ]), 'data' => $items - ->when($this->sort && $this->sort->direction() === 'desc', - fn(Collection $items) => $items->sortByDesc($this->sort->column())) - ->when($this->sort && $this->sort->direction() === 'asc', - fn(Collection $items) => $items->sortBy($this->sort->column())) - ->map(fn(Repository $repository) => $repository->serializeForIndex($request)), + ->when( + $this->sort && $this->sort->direction() === 'desc', + fn (Collection $items) => $items->sortByDesc($this->sort->column()) + ) + ->when( + $this->sort && $this->sort->direction() === 'asc', + fn (Collection $items) => $items->sortBy($this->sort->column()) + ) + ->map(fn (Repository $repository) => $repository->serializeForIndex($request)), ]); } From c8efddfffd09e23b4cc66d65c70156bb243e95be Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Tue, 12 Jul 2022 16:36:12 +0300 Subject: [PATCH 25/42] Relationships nested (#473) * fix: eager loading nested relationships * Fix styling * fix: wip * fix: eager loading nested relationships * fix: wip Co-authored-by: binaryk --- ROADMAP.md | 9 +- docs-v2/content/en/api/relations.md | 101 +++++++++++++++++- src/Eager/Related.php | 7 +- src/Eager/RelatedCollection.php | 11 +- src/Fields/BelongsToMany.php | 3 +- src/Fields/EagerField.php | 6 +- src/Fields/HasMany.php | 3 +- src/Filters/RelatedDto.php | 77 +++++++++++++ src/Http/Requests/RestifyRequest.php | 4 +- src/LaravelRestifyServiceProvider.php | 3 + src/Repositories/Repository.php | 32 +++++- .../Search/RepositorySearchService.php | 2 +- src/Traits/HasNested.php | 25 +++++ .../Index/RepositoryIndexControllerTest.php | 27 ++++- tests/Feature/Filters/BelongsToFilterTest.php | 64 ++++++++--- tests/Fields/HasManyTest.php | 3 + 16 files changed, 338 insertions(+), 39 deletions(-) create mode 100644 src/Traits/HasNested.php diff --git a/ROADMAP.md b/ROADMAP.md index 3f5e6e22a..b75c32678 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,8 +6,7 @@ - [x] Clean up controllers - [x] Reduce the main Repository class by using traits -- [ ] Request validations should be rewritten -- [ ] Revisit the `InteractWithRepositories` trait and clean model queries accordingly +- [x] Revisit the `InteractWithRepositories` trait and clean model queries accordingly - [x] Clean up all tests using AssertableJson [x] - [x] Make sure the `include` matches array key firstly, and secondly the relationship name @@ -17,7 +16,7 @@ - [x] Ensure `$with` loads relationship in `show` requests - [x] Make sure any action isn't permitted unless the Model Policy exists - [x] Having a helper method that allow to return data using the repository from a custom controller `PostRepository::withModels(Post::query()->take(5)->get())->include('user')->serializeForShow()` -- [ ] Serialize nested relationships +- [x] Serialize nested relationships 8.x @@ -27,8 +26,12 @@ - [ ] Drop Psalm - [ ] Adding PestPHP support - [ ] Adding support for PHPStan and configure the level 4 +- [ ] Request validations should be rewritten ### Features - [ ] Adding a command that lists all Restify registered routes `php artisan restify:routes` - [ ] Ability to make an endpoint public using a policy method +- [ ] UI for Restify +- [ ] Load specific fields for nested relationships +- [ ] Load nested for relationships with a nested level higher than 2 diff --git a/docs-v2/content/en/api/relations.md b/docs-v2/content/en/api/relations.md index 729196bb4..113532d80 100644 --- a/docs-v2/content/en/api/relations.md +++ b/docs-v2/content/en/api/relations.md @@ -38,7 +38,11 @@ Let's take a look over all relationships Restify provides: ### Frontend request -In order to get the related resources, you need to send a `GET` request to the `/users/{user}/posts` or `/users?include=posts` endpoint. +In order to get the related resources, you need to send a `GET` request to: + +```http request +GET `/api/restify/users?include=posts` +``` Sometimes you might want to load specific columns from the database into the response. For example, if you have a `Post` model with an `id`, `title` and a `description` column, you might want to load only the `title` and the `description` column in the response. @@ -48,6 +52,101 @@ In order to do this, you can use in the request: GET /users/1?include=posts[title|description] ``` +### Nested relationships + +Let's assume you have the `CompanyRepository`: + +```php +public static function related(): array +{ + return [ + 'users' => HasMany::make('users', UserRepository::class), + ]; +} +``` + +In the UserRepository you have a relationship to a list of user posts and roles: + +```php +public static function related(): array +{ + return [ + 'posts' => HasMany::make('posts', PostRepository::class), + 'roles' => MorphToMany::make('roles', RoleRepository::class), + ]; +} +``` + +In order to get the company users with their posts and roles you can follow the [laravel syntax for eager loading](https://laravel.com/docs/master/eloquent-relationships#nested-eager-loading) into the request query: + +```http request +GET: /api/restify/companies?include=users.posts,users.roles +``` + +This request will return a list like this: + +```json +{ + "data": { + "id": "91c2bdd0-bf6f-4717-b1c4-a6131843ba56", + "type": "companies", + "attributes": { + "name": "Binar Code" + }, + "relationships": { + "users": [{ + "id": "3", + "type": "users", + "attributes": { + "name": "Eduard" + }, + "meta": { + "authorizedToShow": true, + "authorizedToStore": true, + "authorizedToUpdate": false, + "authorizedToDelete": false + }, + "relationships": { + "posts": [{ + "id": "1", + "type": "posts", + "attributes": { + "title": "Post title" + }, + "meta": { + "authorizedToShow": true, + "authorizedToStore": true, + "authorizedToUpdate": false, + "authorizedToDelete": false + } + }], + "roles": [{ + "id": "1", + "type": "roles", + "attributes": { + "name": "admin" + }, + "meta": { + "authorizedToShow": true, + "authorizedToStore": true, + "authorizedToUpdate": false, + "authorizedToDelete": false + } + }] + } + }] + }, + "meta": { + "authorizedToShow": true, + "authorizedToStore": true, + "authorizedToUpdate": true, + "authorizedToDelete": true + } + } +} +``` + + ## BelongsTo & MorphOne The `BelongsTo` and `MorphOne` eager fields works in a similar way. So let's take the `BelongsTo` as an example. diff --git a/src/Eager/Related.php b/src/Eager/Related.php index 1138f30bd..0adb61f09 100644 --- a/src/Eager/Related.php +++ b/src/Eager/Related.php @@ -6,6 +6,7 @@ use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Traits\HasColumns; +use Binaryk\LaravelRestify\Traits\HasNested; use Binaryk\LaravelRestify\Traits\Make; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; @@ -19,6 +20,7 @@ class Related implements JsonSerializable { use Make; use HasColumns; + use HasNested; private string $relation; @@ -62,11 +64,14 @@ public function resolveField(Repository $repository): EagerField return $this ->field ->columns($this->getColumns()) + ->nested(Arr::wrap($this->nested ?: [])) ->resolve($repository); } public function resolve(RestifyRequest $request, Repository $repository): self { + $request->related()->resolved($repository::uriKey() . $repository->getKey() . $this->getRelation()); + if (is_callable($this->resolverCallback)) { $this->value = call_user_func($this->resolverCallback, $request, $repository); @@ -84,7 +89,7 @@ public function resolve(RestifyRequest $request, Repository $repository): self } /** * To avoid circular relationships and deep stack calls, we will do not load eager fields. */ - if ($this->isEager() && $repository->isEagerState() === false) { + if ($this->isEager()) { $this->value = $this->resolveField($repository)->value; return $this; diff --git a/src/Eager/RelatedCollection.php b/src/Eager/RelatedCollection.php index 22a906d32..bf6556c63 100644 --- a/src/Eager/RelatedCollection.php +++ b/src/Eager/RelatedCollection.php @@ -92,10 +92,11 @@ public function forIndex(RestifyRequest $request, Repository $repository): self }); } - public function inRequest(RestifyRequest $request): self + public function inRequest(RestifyRequest $request, Repository $repository): self { $queryRelated = collect($request->related()->related) ->transform(fn ($related) => Str::before($related, '[')) + ->filter(fn ($related) => ! in_array($repository::uriKey() . $repository->getKey() . $related, $request->related()->resolvedRelationships, true)) ->all(); return $this @@ -114,9 +115,11 @@ function (Related $related) use ($value) { } } ); - })->map(fn (Related $related) => $related->columns( - $request->related()->getColumnsFor($related->getRelation()) - )); + })->map( + fn (Related $related) => $related + ->columns($request->related()->getColumnsFor($related->getRelation())) + ->nested($request->related()->getNestedFor($related->getRelation())) + ); } public function authorized(RestifyRequest $request) diff --git a/src/Fields/BelongsToMany.php b/src/Fields/BelongsToMany.php index d4dc1e8e3..75af64f91 100644 --- a/src/Fields/BelongsToMany.php +++ b/src/Fields/BelongsToMany.php @@ -60,13 +60,12 @@ public function resolve($repository, $attribute = null) try { return $this->repositoryClass::resolveWith($item) ->allowToShow(app(Request::class)) - ->columns($this->getColumns()) ->withPivots( PivotsCollection::make($this->pivotFields) ->map(fn (Field $field) => clone $field) ->resolveFromPivot($item->pivot) ) - ->eagerState(); + ->eager($this); } catch (AuthorizationException) { return null; } diff --git a/src/Fields/EagerField.php b/src/Fields/EagerField.php index d72dbb13d..070320b92 100644 --- a/src/Fields/EagerField.php +++ b/src/Fields/EagerField.php @@ -4,6 +4,7 @@ use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Traits\HasColumns; +use Binaryk\LaravelRestify\Traits\HasNested; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; @@ -13,6 +14,7 @@ class EagerField extends Field { use HasColumns; + use HasNested; /** * Name of the relationship. @@ -60,8 +62,8 @@ public function resolve($repository, $attribute = null) try { $this->value = $this->repositoryClass::resolveWith($relatedModel) ->allowToShow(app(Request::class)) - ->columns($this->getColumns()) - ->eagerState(); + ->columns() + ->eager($this); } catch (AuthorizationException $e) { if (is_null($relatedModel)) { abort(403, 'You are not authorized to perform this action.'); diff --git a/src/Fields/HasMany.php b/src/Fields/HasMany.php index 2b998cb9f..bb9ca1e1d 100644 --- a/src/Fields/HasMany.php +++ b/src/Fields/HasMany.php @@ -47,8 +47,7 @@ public function resolve($repository, $attribute = null) try { return $this->repositoryClass::resolveWith($item) ->allowToShow(app(Request::class)) - ->columns($this->getColumns()) - ->eagerState(); + ->eager($this); } catch (AuthorizationException) { return null; } diff --git a/src/Filters/RelatedDto.php b/src/Filters/RelatedDto.php index 1047f5ec4..e8a84956f 100644 --- a/src/Filters/RelatedDto.php +++ b/src/Filters/RelatedDto.php @@ -2,13 +2,21 @@ namespace Binaryk\LaravelRestify\Filters; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Illuminate\Support\Str; +use Illuminate\Support\Stringable; use Spatie\DataTransferObject\DataTransferObject; class RelatedDto extends DataTransferObject { public array $related = []; + public array $nested = []; + + public array $resolvedRelationships = []; + + private bool $loaded = false; + public function getColumnsFor(string $relation): array|string { $related = collect($this->related)->first(fn ($related) => $relation === Str::before($related, '[')); @@ -23,4 +31,73 @@ public function getColumnsFor(string $relation): array|string ? $columns : '*'; } + + public function getNestedFor(string $relation): ?array + { + // TODO: work here to support many nested levels + return collect( + collect($this->nested)->first(fn ($related, $key) => $relation === $key) + )->map(fn (self $nested) => [$nested->related])->flatten()->all(); + } + + public function normalize(): self + { + $this->related = collect($this->related)->map(function (string $relationship) { + if (str($relationship)->contains('.')) { + $baseRelationship = str($relationship)->before('.')->toString(); + + $this->nested[$baseRelationship][] = (new RelatedDto( + related: [ + str($relationship) + ->after($baseRelationship) + ->whenStartsWith('.', fn (Stringable $string) => $string->replaceFirst('.', '')) + ->ltrim() + ->rtrim() + ->toString(), + ] + )) + ->normalize(); + + return $baseRelationship; + } + + return $relationship; + })->unique()->all(); + + return $this; + } + + public function resolved(string $relationship): self + { + $this->resolvedRelationships[] = $relationship; + + return $this; + } + + public function isResolved(string $relationship): bool + { + return array_key_exists($relationship, $this->resolvedRelationships); + } + + public function sync(RestifyRequest $request): self + { + if (! $this->loaded) { + $this->related = collect(str_getcsv($request->input('related') ?? $request->input('include')))->mapInto(Stringable::class)->map->ltrim()->map->rtrim()->all(); + + $this->normalize(); + + $this->loaded = true; + } + + return $this; + } + + public function reset(): self + { + $this->loaded = false; + + $this->resolvedRelationships = []; + + return $this; + } } diff --git a/src/Http/Requests/RestifyRequest.php b/src/Http/Requests/RestifyRequest.php index 2f019f739..0f3867847 100644 --- a/src/Http/Requests/RestifyRequest.php +++ b/src/Http/Requests/RestifyRequest.php @@ -63,8 +63,6 @@ public function pagination(): PaginationDto public function related(): RelatedDto { - return new RelatedDto( - related: str_getcsv($this->input('related') ?? $this->input('include')) - ); + return app(RelatedDto::class)->sync($this); } } diff --git a/src/LaravelRestifyServiceProvider.php b/src/LaravelRestifyServiceProvider.php index 7a3c67c9d..4936691af 100644 --- a/src/LaravelRestifyServiceProvider.php +++ b/src/LaravelRestifyServiceProvider.php @@ -15,6 +15,7 @@ use Binaryk\LaravelRestify\Commands\SetupCommand; use Binaryk\LaravelRestify\Commands\StoreCommand; use Binaryk\LaravelRestify\Commands\StubCommand; +use Binaryk\LaravelRestify\Filters\RelatedDto; use Binaryk\LaravelRestify\Http\Middleware\RestifyInjector; use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\Contracts\Container\BindingResolutionException; @@ -67,6 +68,8 @@ public function packageBooted(): void if (! App::runningUnitTests()) { app(RoutesBoot::class)->boot(); } + + $this->app->singleton(RelatedDto::class, fn ($app) => new RelatedDto()); } public function packageRegistered(): void diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index ab5a19735..c84689834 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -23,6 +23,7 @@ use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Services\Search\RepositorySearchService; use Binaryk\LaravelRestify\Traits\HasColumns; +use Binaryk\LaravelRestify\Traits\HasNested; use Binaryk\LaravelRestify\Traits\InteractWithSearch; use Binaryk\LaravelRestify\Traits\PerformsQueries; use Illuminate\Database\Eloquent\Model; @@ -57,6 +58,7 @@ class Repository implements RestifySearchable, JsonSerializable use HasColumns; use Mockable; use Testing; + use HasNested; /** * This is named `resource` because of the forwarding properties from DelegatesToResource trait. @@ -512,7 +514,8 @@ public function resolveRelationships($request): array { return static::collectRelated() ->authorized($request) - ->inRequest($request) + ->inRequest($request, $this) + ->merge($this->nested) ->when($request->isShowRequest(), function (RelatedCollection $collection) use ($request) { return $collection->forShow($request, $this); }) @@ -1074,9 +1077,17 @@ public static function getDetachers(): array return static::$detachers; } - public function eagerState($state = true): Repository + public function eager(EagerField $field = null): Repository { - $this->eagerState = $state; + if (! $field) { + $this->eagerState = false; + + return $this; + } + + $this + ->columns($field->getColumns()) + ->nested($field->getNested()); return $this; } @@ -1110,4 +1121,19 @@ public static function serializer(): Serializer { return (new Serializer(app(static::class))); } + + public function nested(array $nested = []): self + { + // Set the nested relationship eager attribute from the related list + collect($nested) + ->map(fn ($key) => static::collectRelated() + ->filter(fn ($related) => $related instanceof EagerField) + ->first(fn (EagerField $k, $value) => $k->getAttribute() === $key)) + ->filter(fn ($related) => $related instanceof EagerField) + ->each(function (EagerField $nestedEagerField) { + $this->nested[$nestedEagerField->getAttribute()] = $nestedEagerField; + }); + + return $this; + } } diff --git a/src/Services/Search/RepositorySearchService.php b/src/Services/Search/RepositorySearchService.php index 701d75792..86c1de5c1 100644 --- a/src/Services/Search/RepositorySearchService.php +++ b/src/Services/Search/RepositorySearchService.php @@ -84,7 +84,7 @@ public function prepareRelations(RestifyRequest $request, Builder | Relation $qu $eager = $this->repository::collectRelated() ->authorized($request) ->forEager($request) - ->inRequest($request) + ->inRequest($request, $this->repository) ->when($request->isIndexRequest(), fn (RelatedCollection $collection) => $collection->forIndex($request, $this->repository)) ->when($request->isShowRequest(), fn (RelatedCollection $collection) => $collection->forShow($request, $this->repository)) ->map(fn (EagerField $field) => $field->relation) diff --git a/src/Traits/HasNested.php b/src/Traits/HasNested.php new file mode 100644 index 000000000..c9bd800f5 --- /dev/null +++ b/src/Traits/HasNested.php @@ -0,0 +1,25 @@ +nested = $nested; + + return $this; + } + + public function getNested(): array + { + return $this->nested; + } + + public function hasNested(): bool + { + return ! empty($this->nested); + } +} diff --git a/tests/Controllers/Index/RepositoryIndexControllerTest.php b/tests/Controllers/Index/RepositoryIndexControllerTest.php index 5e0e1270c..6ad597d97 100644 --- a/tests/Controllers/Index/RepositoryIndexControllerTest.php +++ b/tests/Controllers/Index/RepositoryIndexControllerTest.php @@ -2,7 +2,9 @@ namespace Binaryk\LaravelRestify\Tests\Controllers\Index; +use Binaryk\LaravelRestify\Fields\BelongsToMany; use Binaryk\LaravelRestify\Fields\HasMany; +use Binaryk\LaravelRestify\Fields\MorphToMany; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Tests\Database\Factories\PostFactory; @@ -12,6 +14,8 @@ use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostMergeableRepository; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; use Binaryk\LaravelRestify\Tests\Fixtures\Post\RelatedCastWithAttributes; +use Binaryk\LaravelRestify\Tests\Fixtures\Role\Role; +use Binaryk\LaravelRestify\Tests\Fixtures\Role\RoleRepository; use Binaryk\LaravelRestify\Tests\Fixtures\User\User; use Binaryk\LaravelRestify\Tests\Fixtures\User\UserRepository; use Binaryk\LaravelRestify\Tests\IntegrationTest; @@ -196,8 +200,7 @@ public function it_can_transform_relationship_format_using_config(): void ); } - /** * @test */ - public function it_can_retrieve_nested_relationships(): void + public function test_can_retrieve_nested_relationships(): void { CompanyRepository::partialMock() ->shouldReceive('include') @@ -205,18 +208,34 @@ public function it_can_retrieve_nested_relationships(): void 'users' => HasMany::make('users', UserRepository::class), ]); + UserRepository::partialMock() + ->shouldReceive('include') + ->andReturn([ + 'posts' => HasMany::make('posts', PostRepository::class), + 'roles' => MorphToMany::make('roles', RoleRepository::class), + 'companies' => BelongsToMany::make('companies', CompanyRepository::class), + ]); + Company::factory()->has( User::factory()->has( Post::factory() + )->has( + Role::factory() ) )->create(); - $this->getJson(CompanyRepository::route(null, [ - 'related' => 'users', + $this->withoutExceptionHandling()->getJson(CompanyRepository::route(null, [ + 'related' => 'users.companies.users, users.posts, users.roles', ]))->assertJson( fn (AssertableJson $json) => $json + ->where('data.0.type', 'companies') ->has('data.0.relationships') ->has('data.0.relationships.users') + ->where('data.0.relationships.users.0.type', 'users') + ->has('data.0.relationships.users.0.relationships.posts') + ->where('data.0.relationships.users.0.relationships.posts.0.type', 'posts') + ->where('data.0.relationships.users.0.relationships.roles.0.type', 'roles') + ->where('data.0.relationships.users.0.relationships.companies.0.type', 'companies') ->etc() ); } diff --git a/tests/Feature/Filters/BelongsToFilterTest.php b/tests/Feature/Filters/BelongsToFilterTest.php index edc1ffeee..ef17d011c 100644 --- a/tests/Feature/Filters/BelongsToFilterTest.php +++ b/tests/Feature/Filters/BelongsToFilterTest.php @@ -3,6 +3,7 @@ namespace Binaryk\LaravelRestify\Tests\Feature\Filters; use Binaryk\LaravelRestify\Fields\BelongsTo; +use Binaryk\LaravelRestify\Filters\RelatedDto; use Binaryk\LaravelRestify\Filters\SortableFilter; use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; @@ -13,7 +14,7 @@ class BelongsToFilterTest extends IntegrationTest { - public function test_can_filter_using_belongs_to_field(): void + public function test_can_sort_desc_using_belongs_to_field(): void { PostRepository::$related = [ 'user' => BelongsTo::make('user', UserRepository::class), @@ -52,8 +53,8 @@ public function test_can_filter_using_belongs_to_field(): void 'perPage' => 5, ]))->assertJson( fn (AssertableJson $json) => $json - ->where('data.0.relationships.user.attributes.name', 'Zez') - ->etc() + ->where('data.0.relationships.user.attributes.name', 'Zez') + ->etc() ); $this @@ -64,9 +65,42 @@ public function test_can_filter_using_belongs_to_field(): void 'page' => 4, ]))->assertJson( fn (AssertableJson $json) => $json - ->where('data.5.relationships.user.attributes.name', 'Ame') - ->etc() + ->where('data.5.relationships.user.attributes.name', 'Ame') + ->etc() ); + } + + public function test_can_sort_asc_using_belongs_to_field(): void + { + PostRepository::$related = [ + 'user' => BelongsTo::make('user', UserRepository::class), + ]; + + PostRepository::$sort = [ + 'users.attributes.name' => SortableFilter::make()->setColumn('users.name')->usingRelation( + BelongsTo::make('user', UserRepository::class), + ), + ]; + + $randomUser = User::factory()->create([ + 'name' => 'John Doe', + ]); + + Post::factory(22)->create([ + 'user_id' => $randomUser->id, + ]); + + Post::factory()->create([ + 'user_id' => User::factory()->create([ + 'name' => 'Zez', + ]), + ]); + + Post::factory()->create([ + 'user_id' => User::factory()->create([ + 'name' => 'Ame', + ]), + ]); $this ->getJson(PostRepository::route(query: [ @@ -128,8 +162,8 @@ public function test_can_filter_self_defined_belongs_to_field(): void 'perPage' => 5, ]))->assertJson( fn (AssertableJson $json) => $json - ->where('data.0.relationships.user.attributes.name', 'Zez') - ->etc() + ->where('data.0.relationships.user.attributes.name', 'Zez') + ->etc() ); $this @@ -141,10 +175,12 @@ public function test_can_filter_self_defined_belongs_to_field(): void 'page' => 4, ]))->assertJson( fn (AssertableJson $json) => $json - ->where('data.5.relationships.user.attributes.name', 'Ame') - ->etc() + ->where('data.5.relationships.user.attributes.name', 'Ame') + ->etc() ); + app(RelatedDto::class)->reset(); + $this ->getJson(PostRepository::route(query: [ 'related' => 'user', @@ -152,10 +188,12 @@ public function test_can_filter_self_defined_belongs_to_field(): void 'perPage' => 5, ]))->assertJson( fn (AssertableJson $json) => $json - ->where('data.0.relationships.user.attributes.name', 'Ame') - ->etc() + ->where('data.0.relationships.user.attributes.name', 'Ame') + ->etc() ); + app(RelatedDto::class)->reset(); + $this ->getJson(PostRepository::route(query: [ 'related' => 'user', @@ -164,8 +202,8 @@ public function test_can_filter_self_defined_belongs_to_field(): void 'page' => 4, ]))->assertJson( fn (AssertableJson $json) => $json - ->where('data.5.relationships.user.attributes.name', 'Zez') - ->etc() + ->where('data.5.relationships.user.attributes.name', 'Zez') + ->etc() ); } } diff --git a/tests/Fields/HasManyTest.php b/tests/Fields/HasManyTest.php index 6fa88a982..ab586c232 100644 --- a/tests/Fields/HasManyTest.php +++ b/tests/Fields/HasManyTest.php @@ -3,6 +3,7 @@ namespace Binaryk\LaravelRestify\Tests\Fields; use Binaryk\LaravelRestify\Fields\HasMany; +use Binaryk\LaravelRestify\Filters\RelatedDto; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; @@ -76,6 +77,8 @@ public function test_has_many_could_choose_columns(): void ->etc() ); + app(RelatedDto::class)->reset(); + $this->getJson(UserWithPosts::route($user->getKey(), ['related' => 'posts[title|description]'])) ->assertJson( fn (AssertableJson $json) => $json From 5a9b405289f0eec7c105c6371985fdcb22fad7f5 Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Wed, 13 Jul 2022 16:51:01 +0300 Subject: [PATCH 26/42] fix: don't check related if no query (#475) --- src/Filters/RelatedDto.php | 5 +++++ src/Repositories/Repository.php | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/src/Filters/RelatedDto.php b/src/Filters/RelatedDto.php index 1047f5ec4..bc97ca632 100644 --- a/src/Filters/RelatedDto.php +++ b/src/Filters/RelatedDto.php @@ -23,4 +23,9 @@ public function getColumnsFor(string $relation): array|string ? $columns : '*'; } + + public function hasRelated(): bool + { + return ! empty($this->related); + } } diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index fe55afdf7..15fa59e0a 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -510,6 +510,10 @@ public function resolveIndexPivots(RestifyRequest $request): array */ public function resolveRelationships($request): array { + if (! $request->related()->hasRelated()) { + return []; + } + return static::collectRelated() ->authorized($request) ->inRequest($request) From f849a30bcc4e1cf43573765662a089e85a7baebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20B=C4=83nciulea?= Date: Thu, 14 Jul 2022 19:14:15 +0300 Subject: [PATCH 27/42] Fix after bulk method calls (#476) * fix after bulk method calls * fix number of calls * revert returns --- .../RepositoryDestroyBulkController.php | 2 + .../RepositoryUpdateBulkController.php | 3 + src/Repositories/Repository.php | 17 ++- tests/Unit/RepositoryAfterBulkTest.php | 129 ++++++++++++++++++ 4 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/RepositoryAfterBulkTest.php diff --git a/src/Http/Controllers/RepositoryDestroyBulkController.php b/src/Http/Controllers/RepositoryDestroyBulkController.php index e45f60801..ef10157a8 100644 --- a/src/Http/Controllers/RepositoryDestroyBulkController.php +++ b/src/Http/Controllers/RepositoryDestroyBulkController.php @@ -30,6 +30,8 @@ public function __invoke(RepositoryDestroyBulkRequest $request) }); }); + $request->repository()::deletedBulk($collection, $request); + return ok(); } } diff --git a/src/Http/Controllers/RepositoryUpdateBulkController.php b/src/Http/Controllers/RepositoryUpdateBulkController.php index bf7ade8e6..9d242198b 100644 --- a/src/Http/Controllers/RepositoryUpdateBulkController.php +++ b/src/Http/Controllers/RepositoryUpdateBulkController.php @@ -30,6 +30,9 @@ public function __invoke(RepositoryUpdateBulkRequest $request) }); }); + $request->repository()::savedBulk($collection, $request); + $request->repository()::updatedBulk($collection, $request); + return $this->response() ->success(); } diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 15fa59e0a..056c59e6e 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -712,6 +712,7 @@ public function storeBulk(RepositoryStoreBulkRequest $request) }); }); + static::savedBulk($entities, $request); static::storedBulk($entities, $request); return $this->response() @@ -806,8 +807,6 @@ public function updateBulk(RestifyRequest $request, $repositoryId, int $row) ->authorizedUpdateBulk($request) ->each(fn (Field $field) => $field->actionHandler->handle($request, $this->resource, $row)); - static::updatedBulk($this->resource, $request); - return response()->json(); } @@ -823,8 +822,6 @@ public function deleteBulk(RestifyRequest $request, $repositoryId, int $row) return $this->resource->delete(); }); - static::deleted($status, $request); - return ok(code: 204); } @@ -993,7 +990,17 @@ public static function storedBulk(Collection $repositories, $request) // } - public static function updatedBulk($model, $request) + public static function updatedBulk(Collection $repositories, $request) + { + // + } + + public static function savedBulk(Collection $repositories, $request) + { + // + } + + public static function deletedBulk(Collection $repositories, $request) { // } diff --git a/tests/Unit/RepositoryAfterBulkTest.php b/tests/Unit/RepositoryAfterBulkTest.php new file mode 100644 index 000000000..e50d0dac1 --- /dev/null +++ b/tests/Unit/RepositoryAfterBulkTest.php @@ -0,0 +1,129 @@ +make(); + + $this->postJson(WithAfterBulkOverrides::uriKey().'/bulk', [ + [ + 'name' => $user->name, + 'email' => 'test@example.com', + 'password' => $user->password, + ] + ])->assertSuccessful(); + + $this->assertEquals('stored@test.example.com', $user->first()->email); + } + + public function test_it_calls_the_overriden_updated_bulk_method(): void + { + $user = User::factory()->create(); + + $this->postJson(WithAfterBulkOverrides::uriKey().'/bulk/update', [ + [ + 'id' => $user->id, + 'email' => 'test@example.com', + ] + ])->assertSuccessful(); + + $this->assertEquals('updated@test.example.com', $user->fresh()->email); + } + + public function test_it_calls_the_overriden_saved_bulk_method_for_create(): void + { + $user = User::factory()->make(); + + $this->postJson(WithAfterBulkOverrides::uriKey().'/bulk', [ + [ + 'name' => $user->name, + 'email' => 'test@example.com', + 'password' => $user->password, + ] + ])->assertSuccessful(); + + $this->assertEquals('John Saved', $user->first()->name); + } + + public function test_it_calls_the_overriden_saved_bulk_method_for_update(): void + { + $user = User::factory()->create(); + + $this->postJson(WithAfterBulkOverrides::uriKey().'/bulk/update', [ + [ + 'id' => $user->id, + 'email' => 'test@example.com', + ] + ])->assertSuccessful(); + + $this->assertEquals('John Saved', $user->fresh()->name); + } + + public function test_it_calls_the_overriden_deleted_bulk_method(): void + { + $user = User::factory()->create(); + + $this->deleteJson(WithAfterBulkOverrides::uriKey().'/bulk/delete', [ + $user->id, + ])->assertSuccessful(); + + $this->assertDatabaseHas(User::class, [ + 'email' => 'new@example.com', + ]); + } +} + +class WithAfterBulkOverrides extends UserRepository +{ + public static function storedBulk(Collection $repositories, $request) + { + $user = User::find($repositories->first()['id']); + + $user->update([ + 'email' => 'stored@test.example.com', + ]); + } + + public static function updatedBulk(Collection $repositories, $request) + { + $user = User::find($repositories->first()['id']); + + $user->update([ + 'email' => 'updated@test.example.com', + ]); + } + + public static function savedBulk(Collection $repositories, $request) + { + $user = User::find($repositories->first()['id']); + + $user->update([ + 'name' => 'John Saved', + ]); + } + + public static function deletedBulk(Collection $repositories, $request) + { + User::factory()->create([ + 'email' => 'new@example.com', + ]); + } +} From 30ad5024b8fcb44069600cd034f4acce2b29fee9 Mon Sep 17 00:00:00 2001 From: binaryk Date: Thu, 14 Jul 2022 16:14:37 +0000 Subject: [PATCH 28/42] Fix styling --- tests/Unit/RepositoryAfterBulkTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Unit/RepositoryAfterBulkTest.php b/tests/Unit/RepositoryAfterBulkTest.php index e50d0dac1..f899a9dd7 100644 --- a/tests/Unit/RepositoryAfterBulkTest.php +++ b/tests/Unit/RepositoryAfterBulkTest.php @@ -28,7 +28,7 @@ public function test_it_calls_the_overriden_stored_bulk_method(): void 'name' => $user->name, 'email' => 'test@example.com', 'password' => $user->password, - ] + ], ])->assertSuccessful(); $this->assertEquals('stored@test.example.com', $user->first()->email); @@ -42,7 +42,7 @@ public function test_it_calls_the_overriden_updated_bulk_method(): void [ 'id' => $user->id, 'email' => 'test@example.com', - ] + ], ])->assertSuccessful(); $this->assertEquals('updated@test.example.com', $user->fresh()->email); @@ -57,7 +57,7 @@ public function test_it_calls_the_overriden_saved_bulk_method_for_create(): void 'name' => $user->name, 'email' => 'test@example.com', 'password' => $user->password, - ] + ], ])->assertSuccessful(); $this->assertEquals('John Saved', $user->first()->name); @@ -71,7 +71,7 @@ public function test_it_calls_the_overriden_saved_bulk_method_for_update(): void [ 'id' => $user->id, 'email' => 'test@example.com', - ] + ], ])->assertSuccessful(); $this->assertEquals('John Saved', $user->fresh()->name); From 4b767f6c86a93fb487c426c1d504423ed6de0efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20B=C4=83nciulea?= Date: Fri, 15 Jul 2022 09:57:03 +0300 Subject: [PATCH 29/42] Keep model attributes for deletedBulk method. (#477) * fix after bulk method calls * fix number of calls * revert returns * keep model attributes --- .../Controllers/RepositoryDestroyBulkController.php | 6 +++++- tests/Unit/RepositoryAfterBulkTest.php | 10 ++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Http/Controllers/RepositoryDestroyBulkController.php b/src/Http/Controllers/RepositoryDestroyBulkController.php index ef10157a8..f626650b9 100644 --- a/src/Http/Controllers/RepositoryDestroyBulkController.php +++ b/src/Http/Controllers/RepositoryDestroyBulkController.php @@ -8,6 +8,8 @@ class RepositoryDestroyBulkController { + private array $repositories = []; + public function __invoke(RepositoryDestroyBulkRequest $request) { $collection = DB::transaction(function () use ($request) { @@ -15,6 +17,8 @@ public function __invoke(RepositoryDestroyBulkRequest $request) ->each(function (int|string $key, int $row) use ($request) { $model = $request->modelQuery($key)->lockForUpdate()->firstOrFail(); + $this->repositories[] = $model->attributesToArray(); + /** * @var Repository $repository */ @@ -30,7 +34,7 @@ public function __invoke(RepositoryDestroyBulkRequest $request) }); }); - $request->repository()::deletedBulk($collection, $request); + $request->repository()::deletedBulk(collect($this->repositories), $request); return ok(); } diff --git a/tests/Unit/RepositoryAfterBulkTest.php b/tests/Unit/RepositoryAfterBulkTest.php index f899a9dd7..7487718f9 100644 --- a/tests/Unit/RepositoryAfterBulkTest.php +++ b/tests/Unit/RepositoryAfterBulkTest.php @@ -85,8 +85,11 @@ public function test_it_calls_the_overriden_deleted_bulk_method(): void $user->id, ])->assertSuccessful(); + $this->assertDatabaseMissing(User::class, ['id' => $user->id]); + $this->assertDatabaseHas(User::class, [ - 'email' => 'new@example.com', + 'email' => $user->email, + 'name' => $user->name, ]); } } @@ -122,8 +125,11 @@ public static function savedBulk(Collection $repositories, $request) public static function deletedBulk(Collection $repositories, $request) { + $first = $repositories->first(); + User::factory()->create([ - 'email' => 'new@example.com', + 'email' => $first['email'], + 'name' => $first['name'], ]); } } From 98d7e9e2285e5dabb8db2bd487488c9f5f41f626 Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Fri, 15 Jul 2022 10:07:05 +0300 Subject: [PATCH 30/42] fix: fix psalm and tests (#478) * fix: fix psalm and tests * fix: wip * fix: wip --- composer.json | 5 ++++- .../Controllers/RepositoryDestroyBulkController.php | 12 ++++++------ src/Repositories/Repository.php | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/composer.json b/composer.json index 4b39a5786..ad13895ff 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,10 @@ "test-coverage": "./vendor/bin/phpunit --coverage-html coverage" }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "phpstan/extension-installer": true + } }, "extra": { "laravel": { diff --git a/src/Http/Controllers/RepositoryDestroyBulkController.php b/src/Http/Controllers/RepositoryDestroyBulkController.php index f626650b9..ed6bfc9ed 100644 --- a/src/Http/Controllers/RepositoryDestroyBulkController.php +++ b/src/Http/Controllers/RepositoryDestroyBulkController.php @@ -8,16 +8,16 @@ class RepositoryDestroyBulkController { - private array $repositories = []; - public function __invoke(RepositoryDestroyBulkRequest $request) { - $collection = DB::transaction(function () use ($request) { + $repositories = collect(); + + DB::transaction(function () use ($request, $repositories) { return $request->collect() - ->each(function (int|string $key, int $row) use ($request) { + ->each(function (int|string $key, int $row) use ($request, $repositories) { $model = $request->modelQuery($key)->lockForUpdate()->firstOrFail(); - $this->repositories[] = $model->attributesToArray(); + $repositories->push($model->attributesToArray()); /** * @var Repository $repository @@ -34,7 +34,7 @@ public function __invoke(RepositoryDestroyBulkRequest $request) }); }); - $request->repository()::deletedBulk(collect($this->repositories), $request); + $request->repository()::deletedBulk($repositories, $request); return ok(); } diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 056c59e6e..825ef8461 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -38,7 +38,7 @@ use ReturnTypeWillChange; /** - * @property static $type Repository type + * @property $type Repository type */ abstract class Repository implements RestifySearchable, JsonSerializable { @@ -812,7 +812,7 @@ public function updateBulk(RestifyRequest $request, $repositoryId, int $row) public function deleteBulk(RestifyRequest $request, $repositoryId, int $row) { - $status = DB::transaction(function () use ($request) { + DB::transaction(function () use ($request) { if (in_array(HasActionLogs::class, class_uses_recursive($this->resource))) { Restify::actionLog() ->forRepositoryDestroy($this->resource, $request->user()) From 94af860ba25007cc6ea5e6e0df87cd43844d387e Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Fri, 15 Jul 2022 10:09:43 +0300 Subject: [PATCH 31/42] Performance (#474) * fix: catch issues * Fix styling * fix: ensure eager loading works * Fix styling * fix: performance improvements * Fix styling * fix: related performance improvements * Fix styling * fix: drop cast support * Fix styling * fix: optimize current repository search Co-authored-by: binaryk --- ROADMAP.md | 3 +- UPGRADING.md | 20 +++++ composer.json | 2 +- config/restify.php | 16 ---- src/Eager/Related.php | 10 --- src/Eager/RelatedCollection.php | 29 ++++++- src/Filters/AdvancedFiltersCollection.php | 4 + src/Filters/RelatedDto.php | 37 ++++++++- .../Concerns/InteractWithRepositories.php | 7 ++ src/Repositories/Casts/RelatedCast.php | 23 ------ src/Repositories/Repository.php | 80 ++++++++++--------- src/Repositories/RepositoryEvents.php | 11 +-- src/Repositories/RepositoryInstance.php | 16 ++++ .../Search/RepositorySearchService.php | 43 ++++++---- src/helpers.php | 8 ++ .../Index/RepositoryIndexControllerTest.php | 25 +----- tests/Fields/BelongsToManyFieldTest.php | 2 +- tests/Fields/ImageTest.php | 2 +- .../Post/RelatedCastWithAttributes.php | 28 ------- 19 files changed, 194 insertions(+), 172 deletions(-) delete mode 100644 src/Repositories/Casts/RelatedCast.php create mode 100644 src/Repositories/RepositoryInstance.php delete mode 100644 tests/Fixtures/Post/RelatedCastWithAttributes.php diff --git a/ROADMAP.md b/ROADMAP.md index b75c32678..0cfcfd9c3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,13 +2,14 @@ 7.x -### Fixes +### Fixes & Improvements - [x] Clean up controllers - [x] Reduce the main Repository class by using traits - [x] Revisit the `InteractWithRepositories` trait and clean model queries accordingly - [x] Clean up all tests using AssertableJson [x] - [x] Make sure the `include` matches array key firstly, and secondly the relationship name +- [ ] Improve performance for queries and relationships ### Features diff --git a/UPGRADING.md b/UPGRADING.md index 227895e90..eb7cb4595 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -10,6 +10,26 @@ High impact: - Repository.php: - static `to` method renamed to `route` - `related` static method deleted, replace with `include` +- Relations that are present into `include` or `related` will be preloaded, so if you didn't specify a repository to serialize the related relationship, and you're looking for the Eloquent to resolve it, it will do not invoke the `restify.casts.related` cast anymore, instead it'll load the relationship as it. This has a performance reason under the hood. +- Since related relationships will be preloaded, the format of the belongs to will be changed now. If you didn't specify the repository to serialize the `belongsTo` relationship, it'll be serialized as an object, not array anymore: + +Before: +```json +"relationships": { + "user": [{ + "name": "Foo" + }] +} +``` + +Now: +```json +"relationships": { + "user": { + "name": "Foo" +} +} +``` Low impact: diff --git a/composer.json b/composer.json index 8d5ebd8a3..0f14b9f80 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ } ], "require": { - "php": "^8.1", + "php": "^8.0", "illuminate/contracts": "^9.0", "spatie/data-transfer-object": "^3.1", "spatie/once": "^3.0", diff --git a/config/restify.php b/config/restify.php index 0cb6c081d..b550ad337 100644 --- a/config/restify.php +++ b/config/restify.php @@ -100,22 +100,6 @@ AuthorizeRestify::class, ], - /* - |-------------------------------------------------------------------------- - | Used to format data. - |-------------------------------------------------------------------------- - | - */ - 'casts' => [ - /* - |-------------------------------------------------------------------------- - | Casting the related entities format. - |-------------------------------------------------------------------------- - | - */ - 'related' => \Binaryk\LaravelRestify\Repositories\Casts\RelatedCast::class, - ], - /* |-------------------------------------------------------------------------- | Restify Logs diff --git a/src/Eager/Related.php b/src/Eager/Related.php index 0adb61f09..0654436e9 100644 --- a/src/Eager/Related.php +++ b/src/Eager/Related.php @@ -8,8 +8,6 @@ use Binaryk\LaravelRestify\Traits\HasColumns; use Binaryk\LaravelRestify\Traits\HasNested; use Binaryk\LaravelRestify\Traits\Make; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; @@ -106,14 +104,6 @@ public function resolve(RestifyRequest $request, Repository $repository): self } switch ($paginator) { - case $paginator instanceof Builder: - $this->value = ($repository::$relatedCast)::fromBuilder($request, $paginator, $repository); - - break; - case $paginator instanceof Relation: - $this->value = ($repository::$relatedCast)::fromRelation($request, $paginator, $repository); - - break; case $paginator instanceof Collection: $this->value = $paginator; diff --git a/src/Eager/RelatedCollection.php b/src/Eager/RelatedCollection.php index bf6556c63..bb017f37c 100644 --- a/src/Eager/RelatedCollection.php +++ b/src/Eager/RelatedCollection.php @@ -96,7 +96,11 @@ public function inRequest(RestifyRequest $request, Repository $repository): self { $queryRelated = collect($request->related()->related) ->transform(fn ($related) => Str::before($related, '[')) - ->filter(fn ($related) => ! in_array($repository::uriKey() . $repository->getKey() . $related, $request->related()->resolvedRelationships, true)) + ->filter(fn ($related) => ! in_array( + $repository::uriKey().$repository->getKey().$related, + $request->related()->resolvedRelationships, + true + )) ->all(); return $this @@ -117,8 +121,8 @@ function (Related $related) use ($value) { ); })->map( fn (Related $related) => $related - ->columns($request->related()->getColumnsFor($related->getRelation())) - ->nested($request->related()->getNestedFor($related->getRelation())) + ->columns($request->related()->getColumnsFor($related->getRelation())) + ->nested($request->related()->getNestedFor($related->getRelation())) ); } @@ -134,4 +138,23 @@ public function onlySearchable(RestifyRequest $request): self return $this->forBelongsToRelations($request) ->filter(fn (BelongsTo $field) => $field->isSearchable()); } + + public function forRequest(RestifyRequest $request, Repository $repository): self + { + if (! $request->related()->hasRelated()) { + return self::make([]); + } + + if (currentRepository()::class !== $repository::class) { +// When serializing nested relationships simply load nested + return self::make($repository->getNested()); + } + + return $this + ->authorized($request) + ->inRequest($request, $repository) + ->when($request->isShowRequest(), fn (self $collection) => $collection->forShow($request, $repository)) + ->when($request->isIndexRequest(), fn (self $collection) => $collection->forIndex($request, $repository)) + ->merge($repository->nested); + } } diff --git a/src/Filters/AdvancedFiltersCollection.php b/src/Filters/AdvancedFiltersCollection.php index 12036957d..d79af0fcb 100644 --- a/src/Filters/AdvancedFiltersCollection.php +++ b/src/Filters/AdvancedFiltersCollection.php @@ -20,6 +20,10 @@ public function apply(RestifyRequest $request, $query): self public static function collectQueryFilters(RestifyRequest $request, Repository $repository): self { + if (! $request->input('filters')) { + return static::make([]); + } + $filters = json_decode(base64_decode($request->input('filters')), true); $allowedFilters = $repository->collectAdvancedFilters($request); diff --git a/src/Filters/RelatedDto.php b/src/Filters/RelatedDto.php index e8a84956f..eb53764e7 100644 --- a/src/Filters/RelatedDto.php +++ b/src/Filters/RelatedDto.php @@ -21,6 +21,10 @@ public function getColumnsFor(string $relation): array|string { $related = collect($this->related)->first(fn ($related) => $relation === Str::before($related, '[')); + if (! $related) { + return '*'; + } + if (! (Str::contains($related, '[') && Str::contains($related, ']'))) { return '*'; } @@ -81,8 +85,14 @@ public function isResolved(string $relationship): bool public function sync(RestifyRequest $request): self { + if (empty($query = ($request->input('related') ?? $request->input('include')))) { + $this->loaded = true; + + return $this; + } + if (! $this->loaded) { - $this->related = collect(str_getcsv($request->input('related') ?? $request->input('include')))->mapInto(Stringable::class)->map->ltrim()->map->rtrim()->all(); + $this->related = collect(str_getcsv($query))->mapInto(Stringable::class)->map->ltrim()->map->rtrim()->all(); $this->normalize(); @@ -100,4 +110,29 @@ public function reset(): self return $this; } + + private function makeTreeFor(string $related, ?RelatedDto $dto = null): string + { + if (is_null($dto)) { + return $related; + } + + $child = collect($dto->related)->first(); + + return $this->makeTreeFor("$related.".$child, collect(data_get($dto->nested, $child))->first()); + } + + public function makeTree(): array + { + return collect($this->related)->map( + fn (string $relation) => collect($this->nested[$relation] ?? [null])->map( + fn (?self $nested) => $this->makeTreeFor($relation, $nested) + ) + )->flatten()->all(); + } + + public function hasRelated(): bool + { + return ! empty($this->related); + } } diff --git a/src/Http/Requests/Concerns/InteractWithRepositories.php b/src/Http/Requests/Concerns/InteractWithRepositories.php index 0e33ed1d9..1c30b9f3b 100644 --- a/src/Http/Requests/Concerns/InteractWithRepositories.php +++ b/src/Http/Requests/Concerns/InteractWithRepositories.php @@ -5,6 +5,7 @@ use Binaryk\LaravelRestify\Exceptions\RepositoryException; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; +use Binaryk\LaravelRestify\Repositories\RepositoryInstance; use Binaryk\LaravelRestify\Restify; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -36,6 +37,10 @@ public function repository($key = null): Repository $repository = Restify::repository($key); + if ($repository::class === currentRepository()::class) { + return currentRepository(); + } + throw_unless( $repository::authorizedToUseRepository($this), RepositoryException::unauthorized($repository::uriKey()) @@ -51,6 +56,8 @@ public function repository($key = null): Repository ->through(optional($repository::collectMiddlewares($this))->all()) ->thenReturn(); + app()->singleton(RepositoryInstance::class, fn ($app) => new RepositoryInstance($repository)); + return $repository; } catch (RepositoryException $e) { abort($e->getCode(), $e->getMessage()); diff --git a/src/Repositories/Casts/RelatedCast.php b/src/Repositories/Casts/RelatedCast.php deleted file mode 100644 index ded5c6c2b..000000000 --- a/src/Repositories/Casts/RelatedCast.php +++ /dev/null @@ -1,23 +0,0 @@ -take($request->input('relatablePerPage') ?? ($repository::$defaultRelatablePerPage ?? RestifySearchable::DEFAULT_RELATABLE_PER_PAGE))->get(); - } - - public static function fromRelation(RestifyRequest $request, Relation $relation, Repository $repository): Collection - { - return $relation->take($request->input('relatablePerPage') ?? ($repository::$defaultRelatablePerPage ?? RestifySearchable::DEFAULT_RELATABLE_PER_PAGE))->get(); - } -} diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index c84689834..7c7d8bade 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -5,7 +5,6 @@ use Binaryk\LaravelRestify\Actions\Action; use Binaryk\LaravelRestify\Contracts\RestifySearchable; use Binaryk\LaravelRestify\Eager\Related; -use Binaryk\LaravelRestify\Eager\RelatedCollection; use Binaryk\LaravelRestify\Exceptions\InstanceOfException; use Binaryk\LaravelRestify\Fields\BelongsToMany; use Binaryk\LaravelRestify\Fields\EagerField; @@ -30,7 +29,7 @@ use Illuminate\Http\Request; use Illuminate\Http\Resources\ConditionallyLoadsAttributes; use Illuminate\Http\Resources\DelegatesToResource; -use Illuminate\Pagination\AbstractPaginator; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Routing\Router; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -479,7 +478,7 @@ public function resolveShowMeta($request) { return [ 'authorizedToShow' => $this->authorizedToShow($request), - 'authorizedToStore' => $this->authorizedToStore($request), + 'authorizedToStore' => static::authorizedToStore($request), 'authorizedToUpdate' => $this->authorizedToUpdate($request), 'authorizedToDelete' => $this->authorizedToDelete($request), ]; @@ -513,15 +512,7 @@ public function resolveIndexPivots(RestifyRequest $request): array public function resolveRelationships($request): array { return static::collectRelated() - ->authorized($request) - ->inRequest($request, $this) - ->merge($this->nested) - ->when($request->isShowRequest(), function (RelatedCollection $collection) use ($request) { - return $collection->forShow($request, $this); - }) - ->when($request->isIndexRequest(), function (RelatedCollection $collection) use ($request) { - return $collection->forIndex($request, $this); - }) + ->forRequest($request, $this) ->mapIntoRelated($request) ->map(fn (Related $related) => $related->resolve($request, $this)->getValue()) ->map(function (mixed $items) { @@ -567,8 +558,8 @@ public function index(RestifyRequest $request) ); /** * - * Apply all of the query: search, match, sort, related. - * @var AbstractPaginator $paginator + * Apply search, match, sort, related. + * @var LengthAwarePaginator $paginator */ $paginator = RepositorySearchService::make()->search($request, $this) ->paginate($request->pagination()->perPage ?? static::$defaultPerPage, page: $request->pagination()->page); @@ -579,29 +570,38 @@ public function index(RestifyRequest $request) return $repository->authorizedToShow($request); })->values(); - return response()->json( - $this->filter([ - 'meta' => $this->when( - $meta = $this->resolveIndexMainMeta( - $request, - $models = $items->map(fn (self $repository) => $repository->resource), - RepositoryCollection::meta($paginator->toArray()) - ), - $meta - ), - 'links' => $this->when( - $links = $this->resolveIndexLinks( - $request, - $models, - array_merge(RepositoryCollection::paginationLinks($paginator->toArray()), [ - 'filters' => Restify::path(static::uriKey().'/filters'), - ]) - ), - $links + + $data = $items->map(fn (self $repository) => $repository->serializeForIndex($request)); + + return response()->json($this->filter([ + 'meta' => $this->when( + $meta = $this->resolveIndexMainMeta( + $request, + $models = $items->map(fn (self $repository) => $repository->resource), + [ + 'current_page' => $paginator->currentPage(), + 'from' => $paginator->firstItem(), + 'last_page' => $paginator->lastPage(), + 'path' => $paginator->path(), + 'per_page' => $paginator->perPage(), + 'to' => $paginator->lastItem(), + 'total' => $paginator->total(), + ] ), - 'data' => $items->map(fn (self $repository) => $repository->serializeForIndex($request)), - ]) - ); + $meta + ), + 'links' => $this->when( + $links = $this->resolveIndexLinks($request, $models, [ + 'first' => $paginator->url(1), + 'next' => $paginator->nextPageUrl(), + 'path' => $paginator->path(), + 'prev' => $paginator->previousPageUrl(), + 'filters' => Restify::path(static::uriKey().'/filters'), + ]), + $links + ), + 'data' => $data, + ])); } public function indexCollection(RestifyRequest $request, Collection $items): Collection @@ -994,7 +994,7 @@ public function serializeForShow(RestifyRequest $request): array public function serializeForIndex(RestifyRequest $request): array { - return $this->filter([ + $data = $this->filter([ 'id' => $this->when($id = $this->getId($request), $id), 'type' => $this->when($type = $this->getType($request), $type), 'attributes' => $this->when((bool) $attrs = $this->resolveIndexAttributes($request), $attrs), @@ -1002,6 +1002,8 @@ public function serializeForIndex(RestifyRequest $request): array 'meta' => $this->when(value($meta = $this->resolveIndexMeta($request)), $meta), 'pivots' => $this->when(value($pivots = $this->resolveIndexPivots($request)), $pivots), ]); + + return $data; } protected function getType(RestifyRequest $request): ?string @@ -1127,8 +1129,8 @@ public function nested(array $nested = []): self // Set the nested relationship eager attribute from the related list collect($nested) ->map(fn ($key) => static::collectRelated() - ->filter(fn ($related) => $related instanceof EagerField) - ->first(fn (EagerField $k, $value) => $k->getAttribute() === $key)) + ->filter(fn ($related) => $related instanceof EagerField) + ->first(fn (EagerField $k, $value) => $k->getAttribute() === $key)) ->filter(fn ($related) => $related instanceof EagerField) ->each(function (EagerField $nestedEagerField) { $this->nested[$nestedEagerField->getAttribute()] = $nestedEagerField; diff --git a/src/Repositories/RepositoryEvents.php b/src/Repositories/RepositoryEvents.php index c6238742d..d21ea87a4 100644 --- a/src/Repositories/RepositoryEvents.php +++ b/src/Repositories/RepositoryEvents.php @@ -2,17 +2,8 @@ namespace Binaryk\LaravelRestify\Repositories; -use Binaryk\LaravelRestify\Repositories\Casts\RepositoryCast; - trait RepositoryEvents { - /** - * Used to convert collections for relations. - * - * @var RepositoryCast - */ - public static RepositoryCast $relatedCast; - /** * The array of booted repositories. * @@ -37,7 +28,7 @@ protected static function booting(): void */ protected static function boot(): void { - static::$relatedCast = app(config('restify.casts.related')); + // } /** diff --git a/src/Repositories/RepositoryInstance.php b/src/Repositories/RepositoryInstance.php new file mode 100644 index 000000000..2a3c2344a --- /dev/null +++ b/src/Repositories/RepositoryInstance.php @@ -0,0 +1,16 @@ +repository; + } +} diff --git a/src/Services/Search/RepositorySearchService.php b/src/Services/Search/RepositorySearchService.php index 86c1de5c1..ed1cfcc09 100644 --- a/src/Services/Search/RepositorySearchService.php +++ b/src/Services/Search/RepositorySearchService.php @@ -2,7 +2,6 @@ namespace Binaryk\LaravelRestify\Services\Search; -use Binaryk\LaravelRestify\Eager\RelatedCollection; use Binaryk\LaravelRestify\Events\AdvancedFiltersApplied; use Binaryk\LaravelRestify\Fields\BelongsTo; use Binaryk\LaravelRestify\Fields\EagerField; @@ -14,13 +13,14 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Collection; +use Illuminate\Support\Stringable; class RepositorySearchService { /** * @var Repository */ protected $repository; - public function search(RestifyRequest $request, Repository $repository): Builder | Relation + public function search(RestifyRequest $request, Repository $repository): Builder|Relation { $this->repository = $repository; @@ -79,22 +79,32 @@ public function prepareOrders(RestifyRequest $request, $query) return $query; } - public function prepareRelations(RestifyRequest $request, Builder | Relation $query) + public function prepareRelations(RestifyRequest $request, Builder|Relation $query) { - $eager = $this->repository::collectRelated() - ->authorized($request) - ->forEager($request) - ->inRequest($request, $this->repository) - ->when($request->isIndexRequest(), fn (RelatedCollection $collection) => $collection->forIndex($request, $this->repository)) - ->when($request->isShowRequest(), fn (RelatedCollection $collection) => $collection->forShow($request, $this->repository)) - ->map(fn (EagerField $field) => $field->relation) + $eager = ($this->repository)::collectRelated() + ->forRequest($request, $this->repository) + ->map( + fn ($relation) => $relation instanceof EagerField + ? $relation->relation + : $relation + ) ->values() ->unique() ->all(); - $query->with($eager); + if (empty($eager)) { + return $query; + } + + $filtered = collect($request->related()->makeTree())->filter(fn (string $relationships) => in_array( + str($relationships)->whenContains('.', fn (Stringable $string) => $string->before('.'))->toString(), + $eager, + true, + ))->all(); - return $query->with(($this->repository)::withs()); + return $query->with( + array_merge($filtered, ($this->repository)::withs()) + ); } public function prepareSearchFields(RestifyRequest $request, $query) @@ -164,9 +174,12 @@ public function initializeQueryUsingScout(RestifyRequest $request, Repository $r /** * @var Collection $keys */ - $keys = tap($repository::newModel()->search($request->input('search')), function ($scoutBuilder) use ($repository, $request) { - return $repository::scoutQuery($request, $scoutBuilder); - })->take($repository::$scoutSearchResults)->get()->map->getKey(); + $keys = tap( + $repository::newModel()->search($request->input('search')), + function ($scoutBuilder) use ($repository, $request) { + return $repository::scoutQuery($request, $scoutBuilder); + } + )->take($repository::$scoutSearchResults)->get()->map->getKey(); return $repository::newModel()->newQuery()->whereIn( $repository::newModel()->getQualifiedKeyName(), diff --git a/src/helpers.php b/src/helpers.php index fc6b87d2e..8871d9620 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -2,6 +2,7 @@ use Binaryk\LaravelRestify\Fields\Field; use Binaryk\LaravelRestify\Repositories\Repository; +use Binaryk\LaravelRestify\Repositories\RepositoryInstance; use Binaryk\LaravelRestify\Repositories\Serializer; use Binaryk\LaravelRestify\Restify; use Illuminate\Http\JsonResponse; @@ -61,3 +62,10 @@ function rest(...$models): Serializer ->models(collect($models)); } } + +if (! function_exists('currentRepository')) { + function currentRepository(): Repository + { + return app(RepositoryInstance::class)->current(); + } +} diff --git a/tests/Controllers/Index/RepositoryIndexControllerTest.php b/tests/Controllers/Index/RepositoryIndexControllerTest.php index 6ad597d97..07f106217 100644 --- a/tests/Controllers/Index/RepositoryIndexControllerTest.php +++ b/tests/Controllers/Index/RepositoryIndexControllerTest.php @@ -13,7 +13,6 @@ use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostMergeableRepository; use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; -use Binaryk\LaravelRestify\Tests\Fixtures\Post\RelatedCastWithAttributes; use Binaryk\LaravelRestify\Tests\Fixtures\Role\Role; use Binaryk\LaravelRestify\Tests\Fixtures\Role\RoleRepository; use Binaryk\LaravelRestify\Tests\Fixtures\User\User; @@ -153,7 +152,7 @@ public function it_can_return_related_entity(): void 'related' => 'user', ]))->assertJson( fn (AssertableJson $json) => $json - ->where('data.0.relationships.user.0.name', $name) + ->where('data.0.relationships.user.name', $name) ->etc() ); } @@ -180,26 +179,6 @@ public function test_repository_can_resolve_related_using_callables(): void ); } - /** * @test */ - public function it_can_transform_relationship_format_using_config(): void - { - PostRepository::$related = ['user']; - - config([ - 'restify.casts.related' => RelatedCastWithAttributes::class, - ]); - - PostFactory::one(); - - $this->getJson(PostRepository::route(null, [ - 'related' => 'user', - ]))->assertJson( - fn (AssertableJson $json) => $json - ->has('data.0.relationships.user.0.attributes') - ->etc() - ); - } - public function test_can_retrieve_nested_relationships(): void { CompanyRepository::partialMock() @@ -266,7 +245,7 @@ public function it_can_paginate_keeping_relationships(): void ->assertJson( fn (AssertableJson $json) => $json ->count('data', 1) - ->where('data.0.relationships.user.0.name', $owner) + ->where('data.0.relationships.user.name', $owner) ->etc() ); } diff --git a/tests/Fields/BelongsToManyFieldTest.php b/tests/Fields/BelongsToManyFieldTest.php index b89b41e5c..0aab427c4 100644 --- a/tests/Fields/BelongsToManyFieldTest.php +++ b/tests/Fields/BelongsToManyFieldTest.php @@ -19,7 +19,7 @@ public function test_belongs_to_many_displays_on_relationships_show(): void ); }); - $this->getJson(CompanyRepository::route($company->id, ['include' => 'users'])) + $this->withoutExceptionHandling()->getJson(CompanyRepository::route($company->id, ['include' => 'users'])) ->assertJsonStructure([ 'data' => [ 'relationships' => [ diff --git a/tests/Fields/ImageTest.php b/tests/Fields/ImageTest.php index 0150d4bd8..7891ab270 100644 --- a/tests/Fields/ImageTest.php +++ b/tests/Fields/ImageTest.php @@ -11,7 +11,7 @@ class ImageTest extends IntegrationTest { - public function test_image_has_default() + public function test_image_has_default(): void { $image = Image::make('image')->default($default = 'https://lorempixel.com/500x500.png'); diff --git a/tests/Fixtures/Post/RelatedCastWithAttributes.php b/tests/Fixtures/Post/RelatedCastWithAttributes.php deleted file mode 100644 index 85e16c68c..000000000 --- a/tests/Fixtures/Post/RelatedCastWithAttributes.php +++ /dev/null @@ -1,28 +0,0 @@ -take($request->input('relatablePerPage') ?? ($repository::$defaultRelatablePerPage ?? RestifySearchable::DEFAULT_RELATABLE_PER_PAGE)) - ->get() - ->map(fn ($item) => ['attributes' => $item->toArray()]); - } - - public static function fromRelation(RestifyRequest $request, Relation $relation, Repository $repository): Collection - { - return $relation->take($request->input('relatablePerPage') ?? ($repository::$defaultRelatablePerPage ?? RestifySearchable::DEFAULT_RELATABLE_PER_PAGE)) - ->get() - ->map(fn ($item) => ['attributes' => $item->toArray()]); - } -} From 730933706b3dd468e32e207c862bd71bb0d41200 Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Tue, 19 Jul 2022 09:41:19 +0300 Subject: [PATCH 32/42] Recursive related (#479) * fix: collection * Fix styling * fix: tests * fix: wip * Fix styling * fix: recursive * Fix styling * fix: recursive related including columns * fix: typo * Fix styling Co-authored-by: binaryk --- src/Eager/Related.php | 17 +- src/Eager/RelatedCollection.php | 40 ++-- src/Fields/EagerField.php | 2 - src/Filters/RelatedDto.php | 224 ++++++++++++------ src/Filters/RelatedQuery.php | 59 +++++ src/Filters/RelatedQueryCollection.php | 49 ++++ src/Http/Requests/RestifyRequest.php | 7 +- src/Repositories/Repository.php | 24 +- src/Traits/HasColumns.php | 4 +- src/Traits/HasNested.php | 26 +- .../Index/RepositoryIndexControllerTest.php | 2 +- tests/Unit/RelatedQueryCollectionTest.php | 105 ++++++++ 12 files changed, 417 insertions(+), 142 deletions(-) create mode 100644 src/Filters/RelatedQuery.php create mode 100644 src/Filters/RelatedQueryCollection.php create mode 100644 tests/Unit/RelatedQueryCollectionTest.php diff --git a/src/Eager/Related.php b/src/Eager/Related.php index 0654436e9..72f7e0d19 100644 --- a/src/Eager/Related.php +++ b/src/Eager/Related.php @@ -6,8 +6,9 @@ use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Traits\HasColumns; -use Binaryk\LaravelRestify\Traits\HasNested; use Binaryk\LaravelRestify\Traits\Make; +use Illuminate\Contracts\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; @@ -18,7 +19,6 @@ class Related implements JsonSerializable { use Make; use HasColumns; - use HasNested; private string $relation; @@ -29,7 +29,7 @@ class Related implements JsonSerializable */ private $value; - private ?EagerField $field; + public ?EagerField $field; /** * @var callable @@ -62,13 +62,12 @@ public function resolveField(Repository $repository): EagerField return $this ->field ->columns($this->getColumns()) - ->nested(Arr::wrap($this->nested ?: [])) ->resolve($repository); } public function resolve(RestifyRequest $request, Repository $repository): self { - $request->related()->resolved($repository::uriKey() . $repository->getKey() . $this->getRelation()); + $request->related()->resolved($repository::uriKey().$repository->getKey().$this->getRelation()); if (is_callable($this->resolverCallback)) { $this->value = call_user_func($this->resolverCallback, $request, $repository); @@ -107,6 +106,14 @@ public function resolve(RestifyRequest $request, Repository $repository): self case $paginator instanceof Collection: $this->value = $paginator; + break; + case $paginator instanceof BelongsTo: + $this->value = $paginator->first(); + + break; + case $paginator instanceof Builder: + $this->value = $paginator->get(); + break; default: $this->value = $paginator; diff --git a/src/Eager/RelatedCollection.php b/src/Eager/RelatedCollection.php index bb017f37c..593e6ba27 100644 --- a/src/Eager/RelatedCollection.php +++ b/src/Eager/RelatedCollection.php @@ -13,7 +13,6 @@ use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\Support\Collection; -use Illuminate\Support\Str; class RelatedCollection extends Collection { @@ -94,23 +93,14 @@ public function forIndex(RestifyRequest $request, Repository $repository): self public function inRequest(RestifyRequest $request, Repository $repository): self { - $queryRelated = collect($request->related()->related) - ->transform(fn ($related) => Str::before($related, '[')) - ->filter(fn ($related) => ! in_array( - $repository::uriKey().$repository->getKey().$related, - $request->related()->resolvedRelationships, - true - )) - ->all(); - - return $this - ->filter(fn ($field, $key) => in_array($key, $queryRelated)) - ->unique(); + return $this->filter(function ($field, $key) use ($request, $repository) { + return $request->related()->hasRelation($repository::uriKey() . '.' . $key); + }); } - public function mapIntoRelated(RestifyRequest $request): self + public function mapIntoRelated(RestifyRequest $request, Repository $repository): self { - return $this->map(function ($value, $key) { + return $this->map(function ($value, $key) use ($request) { return tap( Related::make($key, $value instanceof EagerField ? $value : null), function (Related $related) use ($value) { @@ -121,8 +111,7 @@ function (Related $related) use ($value) { ); })->map( fn (Related $related) => $related - ->columns($request->related()->getColumnsFor($related->getRelation())) - ->nested($request->related()->getNestedFor($related->getRelation())) + ->columns($request->related()->getColumnsFor($repository::uriKey().'.'.$related->getRelation())) ); } @@ -145,16 +134,19 @@ public function forRequest(RestifyRequest $request, Repository $repository): sel return self::make([]); } - if (currentRepository()::class !== $repository::class) { -// When serializing nested relationships simply load nested - return self::make($repository->getNested()); - } - return $this ->authorized($request) ->inRequest($request, $repository) ->when($request->isShowRequest(), fn (self $collection) => $collection->forShow($request, $repository)) - ->when($request->isIndexRequest(), fn (self $collection) => $collection->forIndex($request, $repository)) - ->merge($repository->nested); + ->when($request->isIndexRequest(), fn (self $collection) => $collection->forIndex($request, $repository)); + } + + public function unserialized(RestifyRequest $request, Repository $repository) + { + return $this->filter(fn (Related $related) => ! in_array( + $repository::uriKey().$repository->getKey().$related->getRelation(), + $request->related()->resolvedRelationships, + true + )); } } diff --git a/src/Fields/EagerField.php b/src/Fields/EagerField.php index 070320b92..81b8fa844 100644 --- a/src/Fields/EagerField.php +++ b/src/Fields/EagerField.php @@ -4,7 +4,6 @@ use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Traits\HasColumns; -use Binaryk\LaravelRestify\Traits\HasNested; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; @@ -14,7 +13,6 @@ class EagerField extends Field { use HasColumns; - use HasNested; /** * Name of the relationship. diff --git a/src/Filters/RelatedDto.php b/src/Filters/RelatedDto.php index eb53764e7..ae392dbba 100644 --- a/src/Filters/RelatedDto.php +++ b/src/Filters/RelatedDto.php @@ -2,73 +2,57 @@ namespace Binaryk\LaravelRestify\Filters; -use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; -use Illuminate\Support\Str; +use Binaryk\LaravelRestify\Repositories\Repository; +use Illuminate\Http\Request; use Illuminate\Support\Stringable; -use Spatie\DataTransferObject\DataTransferObject; -class RelatedDto extends DataTransferObject +class RelatedDto { - public array $related = []; + public RelatedQueryCollection $related; public array $nested = []; public array $resolvedRelationships = []; - private bool $loaded = false; - - public function getColumnsFor(string $relation): array|string - { - $related = collect($this->related)->first(fn ($related) => $relation === Str::before($related, '[')); - - if (! $related) { - return '*'; - } + public array $relatedArray = []; - if (! (Str::contains($related, '[') && Str::contains($related, ']'))) { - return '*'; - } + private bool $loaded = false; - $columns = explode(',', Str::replace('|', ',', Str::between($related, '[', ']'))); + public function __construct( + ?RelatedQueryCollection $related = null, + ) { + $this->related = $related ?? RelatedQueryCollection::make([]); + } - return count($columns) - ? $columns - : '*'; + public function hasRelated(): bool + { + return ! empty($this->related); } - public function getNestedFor(string $relation): ?array + /** + * Dot notation of the relationship. Could be a nested relation users.posts.tags + * + * @param string $relation + * @return bool + */ + public function hasRelation(string $relation): bool { - // TODO: work here to support many nested levels - return collect( - collect($this->nested)->first(fn ($related, $key) => $relation === $key) - )->map(fn (self $nested) => [$nested->related])->flatten()->all(); + return (bool) $this->getRelatedQueryFor($relation); } - public function normalize(): self + public function getColumnsFor(string $relation): array { - $this->related = collect($this->related)->map(function (string $relationship) { - if (str($relationship)->contains('.')) { - $baseRelationship = str($relationship)->before('.')->toString(); - - $this->nested[$baseRelationship][] = (new RelatedDto( - related: [ - str($relationship) - ->after($baseRelationship) - ->whenStartsWith('.', fn (Stringable $string) => $string->replaceFirst('.', '')) - ->ltrim() - ->rtrim() - ->toString(), - ] - )) - ->normalize(); - - return $baseRelationship; - } + return $this->getRelatedQueryFor($relation)?->columns() ?: ['*']; + } - return $relationship; - })->unique()->all(); + public function getRelatedQueryFor(string $relation): ?RelatedQuery + { + return collect($this->relatedArray)->first(fn ($object, $key) => str($key)->contains($relation)); + } - return $this; + public function getNestedFor(string $relation): ?RelatedQueryCollection + { + return $this->getRelatedQueryFor($relation)?->nested; } public function resolved(string $relationship): self @@ -83,56 +67,146 @@ public function isResolved(string $relationship): bool return array_key_exists($relationship, $this->resolvedRelationships); } - public function sync(RestifyRequest $request): self + public function reset(): self { - if (empty($query = ($request->input('related') ?? $request->input('include')))) { - $this->loaded = true; + $this->loaded = false; - return $this; - } + $this->related = RelatedQueryCollection::make([]); + + $this->resolvedRelationships = []; + + $this->relatedArray = []; - if (! $this->loaded) { - $this->related = collect(str_getcsv($query))->mapInto(Stringable::class)->map->ltrim()->map->rtrim()->all(); + return $this; + } - $this->normalize(); + private function searchInRelatedQuery(RelatedQuery $relatedQuery, string $relation): ?RelatedQuery + { + if ($relatedQuery->matchTree($relation)) { + return $relatedQuery; + } - $this->loaded = true; + if ($relatedQuery->nested->count()) { + return $relatedQuery->nested->first(fn (RelatedQuery $child) => $this->searchInRelatedQuery( + $child, + $relation + )); } - return $this; + return null; } - public function reset(): self + private function makeTreeForChild(RelatedQuery $relatedQuery, array &$base = []): array { - $this->loaded = false; + if ($relatedQuery->nested->count()) { + $relatedQuery->nested->each(function (RelatedQuery $child, $i) use (&$base, $relatedQuery) { + $base[$i] = data_get($base, $i, $relatedQuery->relation).".$child->relation"; + + if ($child->nested->count()) { + $this->makeTreeForChild($child, $base); + } + }); + } else { + return [$relatedQuery->relation]; + } - $this->resolvedRelationships = []; + return $base; + } - return $this; + public function makeTree(): array + { + return collect(array_keys($this->relatedArray)) + ->mapInto(Stringable::class) + ->map(fn (Stringable $relation) => $relation->after('.')) + ->unique() + ->map(fn (Stringable $relation) => $relation->toString()) + ->all(); } - private function makeTreeFor(string $related, ?RelatedDto $dto = null): string + public function sync(Request $request, Repository $repository): self { - if (is_null($dto)) { - return $related; + if ($this->loaded) { + return $this; + } + + if (empty($query = $this->query($request))) { + $this->loaded(); + + return $this; } - $child = collect($dto->related)->first(); + $roots = str($query)->replace(' ', '')->explode(','); + + collect($roots)->map(function (string $related) use ($repository) { + if (str($related)->contains('.')) { + // users[id].comments[id] => users + $relation = str(collect(str($related)->explode('.'))->first())->before('['); + } else { + // comments[id] => comments + // comments => comments + $relation = str($related)->before('['); + } + + /** + * @var RelatedQuery|null $relatedQuery + */ + if ($relatedQuery = $this->related->firstWhere('relation', $relation)) { + $parent = $relatedQuery; + } else { + $parent = RelatedQueryCollection::fromToken(str($related)->before('.'))->parent($repository::uriKey()); + $this->relatedArray[$parent->tree] = clone $parent; + } + + // Here it's like `comments[id]` + if (! str($related)->contains('.')) { + /** + * @var RelatedQuery|null $relatedQuery + */ + if ($relatedQuery = $this->related->firstWhere('relation', $relation)) { + $relatedQuery->nested->push($parent); + } else { + $this->related->push($parent); + } + + $this->loaded(); - return $this->makeTreeFor("$related.".$child, collect(data_get($dto->nested, $child))->first()); + return $this; + } + + /** + * @var RelatedQuery|null $relatedQuery + */ + if (! $this->related->firstWhere('relation', $relation)) { + $this->related->push($parent); + } + + collect(str($related)->after('.')->explode('.'))->map(function (string $nested) use (&$parent) { + $newParent = RelatedQueryCollection::fromToken($nested)->parent($parent->tree); + + $this->relatedArray[$newParent->tree] = $newParent; + + return $parent->nested->push( + $parent = $newParent + ); + }); + + $this->loaded(); + + return $this; + }); + + $this->loaded(); + + return $this; } - public function makeTree(): array + private function loaded(): void { - return collect($this->related)->map( - fn (string $relation) => collect($this->nested[$relation] ?? [null])->map( - fn (?self $nested) => $this->makeTreeFor($relation, $nested) - ) - )->flatten()->all(); + $this->loaded = true; } - public function hasRelated(): bool + private function query(Request $request): ?string { - return ! empty($this->related); + return $request->input('related') ?? $request->input('include'); } } diff --git a/src/Filters/RelatedQuery.php b/src/Filters/RelatedQuery.php new file mode 100644 index 000000000..9080ecfd4 --- /dev/null +++ b/src/Filters/RelatedQuery.php @@ -0,0 +1,59 @@ +nested = $nested ?? RelatedQueryCollection::make([]); + $this->tree = $relation; + $this->serialized = false; + } + + public function columns(): array + { + return $this->columns; + } + + public function notation(string $notation): self + { + $this->tree = $notation; + + return $this; + } + + public function parent(string $parent): self + { + $this->tree = "$parent.$this->tree"; + + return $this; + } + + public function serialized(): self + { + $this->serialized = true; + + return $this; + } + + public function isSerialized(): bool + { + return $this->serialized; + } + + public function matchTree(string $tree): bool + { + return str($this->tree)->contains($tree); + } +} diff --git a/src/Filters/RelatedQueryCollection.php b/src/Filters/RelatedQueryCollection.php new file mode 100644 index 000000000..686194e22 --- /dev/null +++ b/src/Filters/RelatedQueryCollection.php @@ -0,0 +1,49 @@ +before('.')->toString()); + + if (! str($related)->contains('.')) { + return $parent; + } + + collect(str($related)->after('.')->explode('.')) + ->map(function (string $nested, $i) use ($parent) { + if ($i === 0) { + return $parent->nested->push(static::fromToken($nested)); + } + + return $parent->nested->nth($i)->first()->nested->push(static::fromToken($nested)); + }); + + return $parent; + } + + public static function fromToken(string $token): RelatedQuery + { + if (str($token)->contains('[')) { + // has columns + return new RelatedQuery( + relation: str($token)->before('['), + columns: str($token)->between('[', ']')->explode('|')->all(), + ); + } + + if (str($token)->contains('[')) { + // has columns + return new RelatedQuery( + relation: str($token)->before('['), + columns: str($token)->between('[', ']')->explode('|')->all(), + ); + } + + return new RelatedQuery($token); + } +} diff --git a/src/Http/Requests/RestifyRequest.php b/src/Http/Requests/RestifyRequest.php index 0f3867847..0b79b71d9 100644 --- a/src/Http/Requests/RestifyRequest.php +++ b/src/Http/Requests/RestifyRequest.php @@ -8,6 +8,7 @@ use Binaryk\LaravelRestify\Http\Requests\Concerns\DetermineRequestType; use Binaryk\LaravelRestify\Http\Requests\Concerns\InteractWithRepositories; use Illuminate\Foundation\Http\FormRequest; +use Throwable; class RestifyRequest extends FormRequest { @@ -63,6 +64,10 @@ public function pagination(): PaginationDto public function related(): RelatedDto { - return app(RelatedDto::class)->sync($this); + try { + return app(RelatedDto::class)->sync($this, currentRepository() ?? $this->repository()); + } catch (Throwable) { + return app(RelatedDto::class); + } } } diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 7c7d8bade..421eda24f 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -22,7 +22,6 @@ use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Services\Search\RepositorySearchService; use Binaryk\LaravelRestify\Traits\HasColumns; -use Binaryk\LaravelRestify\Traits\HasNested; use Binaryk\LaravelRestify\Traits\InteractWithSearch; use Binaryk\LaravelRestify\Traits\PerformsQueries; use Illuminate\Database\Eloquent\Model; @@ -57,7 +56,6 @@ class Repository implements RestifySearchable, JsonSerializable use HasColumns; use Mockable; use Testing; - use HasNested; /** * This is named `resource` because of the forwarding properties from DelegatesToResource trait. @@ -513,7 +511,8 @@ public function resolveRelationships($request): array { return static::collectRelated() ->forRequest($request, $this) - ->mapIntoRelated($request) + ->mapIntoRelated($request, $this) + ->unserialized($request, $this) ->map(fn (Related $related) => $related->resolve($request, $this)->getValue()) ->map(function (mixed $items) { if ($items instanceof Collection) { @@ -1087,9 +1086,7 @@ public function eager(EagerField $field = null): Repository return $this; } - $this - ->columns($field->getColumns()) - ->nested($field->getNested()); + $this->columns($field->getColumns()); return $this; } @@ -1123,19 +1120,4 @@ public static function serializer(): Serializer { return (new Serializer(app(static::class))); } - - public function nested(array $nested = []): self - { - // Set the nested relationship eager attribute from the related list - collect($nested) - ->map(fn ($key) => static::collectRelated() - ->filter(fn ($related) => $related instanceof EagerField) - ->first(fn (EagerField $k, $value) => $k->getAttribute() === $key)) - ->filter(fn ($related) => $related instanceof EagerField) - ->each(function (EagerField $nestedEagerField) { - $this->nested[$nestedEagerField->getAttribute()] = $nestedEagerField; - }); - - return $this; - } } diff --git a/src/Traits/HasColumns.php b/src/Traits/HasColumns.php index a5cadaba7..d285d4ef2 100644 --- a/src/Traits/HasColumns.php +++ b/src/Traits/HasColumns.php @@ -20,7 +20,9 @@ public function columns(array|string $columns = []): self public function getColumns(): array|string { - return $this->columns; + return $this->columns === ['*'] + ? '*' + : $this->columns; } public function hasCustomColumns(): bool diff --git a/src/Traits/HasNested.php b/src/Traits/HasNested.php index c9bd800f5..27991e9d0 100644 --- a/src/Traits/HasNested.php +++ b/src/Traits/HasNested.php @@ -2,22 +2,24 @@ namespace Binaryk\LaravelRestify\Traits; +use Binaryk\LaravelRestify\Filters\RelatedQueryCollection; + trait HasNested { - public array $nested = []; - - public function nested(array $nested = []): self - { - $this->nested = $nested; + public ?RelatedQueryCollection $nested = null; - return $this; - } - - public function getNested(): array - { - return $this->nested; - } +// public function nested(?RelatedQueryCollection $nested = null): self +// { +// $this->nested = $nested; +// +// return $this; +// } +// public function getNested(): RelatedQueryCollection +// { +// return $this->nested ?? RelatedQueryCollection::make([]); +// } +// public function hasNested(): bool { return ! empty($this->nested); diff --git a/tests/Controllers/Index/RepositoryIndexControllerTest.php b/tests/Controllers/Index/RepositoryIndexControllerTest.php index 07f106217..bd9c6d49e 100644 --- a/tests/Controllers/Index/RepositoryIndexControllerTest.php +++ b/tests/Controllers/Index/RepositoryIndexControllerTest.php @@ -197,7 +197,7 @@ public function test_can_retrieve_nested_relationships(): void Company::factory()->has( User::factory()->has( - Post::factory() + Post::factory()->count(2) )->has( Role::factory() ) diff --git a/tests/Unit/RelatedQueryCollectionTest.php b/tests/Unit/RelatedQueryCollectionTest.php new file mode 100644 index 000000000..15ad4395b --- /dev/null +++ b/tests/Unit/RelatedQueryCollectionTest.php @@ -0,0 +1,105 @@ + 'users[email|name].posts[title].tags[id], users.comments[comment], buildings[title], creator', + ]); + + $company = Company::factory()->create(); + + $relatedDto = app(RelatedDto::class)->sync($request, CompanyRepository::resolveWith($company)); + + $relatedCollection = $relatedDto->related; + + $this->assertSame(['title'], $relatedDto->getColumnsFor('companies.buildings')); + + $this->assertSame([ + 'users', + 'users.posts', + 'users.posts.tags', + 'users.comments', + 'buildings', + 'creator', + ], $relatedDto->makeTree()); + + $this->assertCount(3, $relatedCollection); + + /** + * @var RelatedQuery $usesRelated + */ + $usesRelated = $relatedCollection->first(); + + $this->assertSame('users', $usesRelated->relation); + $this->assertSame(['email', 'name'], $usesRelated->columns); + $this->assertCount(2, $usesRelated->nested); + $this->assertSame('posts', $usesRelated->nested->first()->relation); + $this->assertSame('comments', $usesRelated->nested->last()->relation); + + /** + * @var RelatedQuery $postsNested + */ + $postsNested = $usesRelated->nested->first(); + $this->assertSame('posts', $postsNested->relation); + $this->assertSame(['title'], $postsNested->columns); + $this->assertCount(1, $postsNested->nested); + + /** + * @var RelatedQuery $userPostTagsRelated + */ + $userPostTagsRelated = $postsNested->nested->first(); + $this->assertSame('tags', $userPostTagsRelated->relation); + $this->assertSame(['id'], $userPostTagsRelated->columns); + $this->assertCount(0, $userPostTagsRelated->nested); + + /** + * @var RelatedQuery $userCommentsNested + */ + $userCommentsNested = $usesRelated->nested->last(); + $this->assertSame('comments', $userCommentsNested->relation); + $this->assertSame(['comment'], $userCommentsNested->columns); + $this->assertCount(0, $userCommentsNested->nested); + + /** + * @var RelatedQuery $tagsNested + */ + $tagsNested = $postsNested->nested->first(); + $this->assertSame('tags', $tagsNested->relation); + $this->assertSame(['id'], $tagsNested->columns); + $this->assertCount(0, $tagsNested->nested); + + /** + * @var RelatedQuery $buildingsRelated + */ + $buildingsRelated = $relatedCollection->get(1); + + $this->assertSame('buildings', $buildingsRelated->relation); + $this->assertSame(['title'], $buildingsRelated->columns); + $this->assertCount(0, $buildingsRelated->nested); + + /** + * @var RelatedQuery $creatorRelated + */ + $creatorRelated = $relatedCollection->get(2); + + $this->assertSame('creator', $creatorRelated->relation); + $this->assertSame(['*'], $creatorRelated->columns); + $this->assertCount(0, $creatorRelated->nested); + + $this->assertCount(2, $relatedDto->getNestedFor('companies.users')); + $this->assertSame(['email', 'name'], $relatedDto->getColumnsFor('companies.users')); + $this->assertSame(['title'], $relatedDto->getColumnsFor('companies.buildings')); + $this->assertSame(['*'], $relatedDto->getColumnsFor('creator')); + } +} From 2c3221a1d62d17a73bd9ac03531ff4e2e2adfef3 Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Tue, 19 Jul 2022 10:00:09 +0300 Subject: [PATCH 33/42] Feedback related (#480) * fix: feedback from related pr * Fix styling * fix: wip * fix: wip * Fix styling Co-authored-by: binaryk --- src/Eager/RelatedCollection.php | 2 +- src/Filters/RelatedDto.php | 30 +++++++++---------- src/Filters/RelatedQuery.php | 13 +++++++++ src/Filters/RelatedQueryCollection.php | 40 -------------------------- 4 files changed, 29 insertions(+), 56 deletions(-) diff --git a/src/Eager/RelatedCollection.php b/src/Eager/RelatedCollection.php index 593e6ba27..f848cc4f1 100644 --- a/src/Eager/RelatedCollection.php +++ b/src/Eager/RelatedCollection.php @@ -94,7 +94,7 @@ public function forIndex(RestifyRequest $request, Repository $repository): self public function inRequest(RestifyRequest $request, Repository $repository): self { return $this->filter(function ($field, $key) use ($request, $repository) { - return $request->related()->hasRelation($repository::uriKey() . '.' . $key); + return $request->related()->hasRelation($repository::uriKey().'.'.$key); }); } diff --git a/src/Filters/RelatedDto.php b/src/Filters/RelatedDto.php index ae392dbba..55be6a7b6 100644 --- a/src/Filters/RelatedDto.php +++ b/src/Filters/RelatedDto.php @@ -47,7 +47,7 @@ public function getColumnsFor(string $relation): array public function getRelatedQueryFor(string $relation): ?RelatedQuery { - return collect($this->relatedArray)->first(fn ($object, $key) => str($key)->contains($relation)); + return collect($this->relatedArray)->first(fn ($object, $key) => str_contains($key, $relation)); } public function getNestedFor(string $relation): ?RelatedQueryCollection @@ -98,18 +98,18 @@ private function searchInRelatedQuery(RelatedQuery $relatedQuery, string $relati private function makeTreeForChild(RelatedQuery $relatedQuery, array &$base = []): array { - if ($relatedQuery->nested->count()) { - $relatedQuery->nested->each(function (RelatedQuery $child, $i) use (&$base, $relatedQuery) { - $base[$i] = data_get($base, $i, $relatedQuery->relation).".$child->relation"; - - if ($child->nested->count()) { - $this->makeTreeForChild($child, $base); - } - }); - } else { + if (! $relatedQuery->nested->count()) { return [$relatedQuery->relation]; } + $relatedQuery->nested->each(function (RelatedQuery $child, $i) use (&$base, $relatedQuery) { + $base[$i] = data_get($base, $i, $relatedQuery->relation).".$child->relation"; + + if ($child->nested->count()) { + $this->makeTreeForChild($child, $base); + } + }); + return $base; } @@ -137,8 +137,8 @@ public function sync(Request $request, Repository $repository): self $roots = str($query)->replace(' ', '')->explode(','); - collect($roots)->map(function (string $related) use ($repository) { - if (str($related)->contains('.')) { + collect($roots)->each(function (string $related) use ($repository) { + if (str_contains($related, '.')) { // users[id].comments[id] => users $relation = str(collect(str($related)->explode('.'))->first())->before('['); } else { @@ -153,12 +153,12 @@ public function sync(Request $request, Repository $repository): self if ($relatedQuery = $this->related->firstWhere('relation', $relation)) { $parent = $relatedQuery; } else { - $parent = RelatedQueryCollection::fromToken(str($related)->before('.'))->parent($repository::uriKey()); + $parent = RelatedQuery::fromToken(str($related)->before('.'))->parent($repository::uriKey()); $this->relatedArray[$parent->tree] = clone $parent; } // Here it's like `comments[id]` - if (! str($related)->contains('.')) { + if (! str_contains($related, '.')) { /** * @var RelatedQuery|null $relatedQuery */ @@ -181,7 +181,7 @@ public function sync(Request $request, Repository $repository): self } collect(str($related)->after('.')->explode('.'))->map(function (string $nested) use (&$parent) { - $newParent = RelatedQueryCollection::fromToken($nested)->parent($parent->tree); + $newParent = RelatedQuery::fromToken($nested)->parent($parent->tree); $this->relatedArray[$newParent->tree] = $newParent; diff --git a/src/Filters/RelatedQuery.php b/src/Filters/RelatedQuery.php index 9080ecfd4..ccd742540 100644 --- a/src/Filters/RelatedQuery.php +++ b/src/Filters/RelatedQuery.php @@ -56,4 +56,17 @@ public function matchTree(string $tree): bool { return str($this->tree)->contains($tree); } + + public static function fromToken(string $token): RelatedQuery + { + if (str_contains($token, '[')) { + // has columns + return new RelatedQuery( + relation: str($token)->before('['), + columns: str($token)->between('[', ']')->explode('|')->all(), + ); + } + + return new RelatedQuery($token); + } } diff --git a/src/Filters/RelatedQueryCollection.php b/src/Filters/RelatedQueryCollection.php index 686194e22..5e79f27c6 100644 --- a/src/Filters/RelatedQueryCollection.php +++ b/src/Filters/RelatedQueryCollection.php @@ -6,44 +6,4 @@ class RelatedQueryCollection extends Collection { - public static function fromString(string $related): RelatedQuery - { - $parent = static::fromToken(str($related)->before('.')->toString()); - - if (! str($related)->contains('.')) { - return $parent; - } - - collect(str($related)->after('.')->explode('.')) - ->map(function (string $nested, $i) use ($parent) { - if ($i === 0) { - return $parent->nested->push(static::fromToken($nested)); - } - - return $parent->nested->nth($i)->first()->nested->push(static::fromToken($nested)); - }); - - return $parent; - } - - public static function fromToken(string $token): RelatedQuery - { - if (str($token)->contains('[')) { - // has columns - return new RelatedQuery( - relation: str($token)->before('['), - columns: str($token)->between('[', ']')->explode('|')->all(), - ); - } - - if (str($token)->contains('[')) { - // has columns - return new RelatedQuery( - relation: str($token)->before('['), - columns: str($token)->between('[', ']')->explode('|')->all(), - ); - } - - return new RelatedQuery($token); - } } From c27beca572e44a5df1a18dd2e1d965e1cfbb777e Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Tue, 19 Jul 2022 12:36:35 +0300 Subject: [PATCH 34/42] Dynamic meta (#481) * fix: configurable meta render * Fix styling Co-authored-by: binaryk --- config/restify.php | 12 +++++ src/Commands/stubs/policy.stub | 4 +- src/Repositories/Repository.php | 23 +++++++++- tests/IntegrationTest.php | 2 + .../Repositories/RepositorySerializerTest.php | 46 +++++++++++++++++++ 5 files changed, 84 insertions(+), 3 deletions(-) diff --git a/config/restify.php b/config/restify.php index b550ad337..37276e0fd 100644 --- a/config/restify.php +++ b/config/restify.php @@ -133,4 +133,16 @@ */ 'case_sensitive' => true, ], + + 'repositories' => [ + /* + | Specify either to serialize index meta (policy) information or not. For performance reasons we recommend to disable it. + */ + 'serialize_index_meta' => false, + + /* + | Specify either to serialize show meta (policy) information or not. + */ + 'serialize_show_meta' => true, + ], ]; diff --git a/src/Commands/stubs/policy.stub b/src/Commands/stubs/policy.stub index 2de819bd5..16a950f6f 100644 --- a/src/Commands/stubs/policy.stub +++ b/src/Commands/stubs/policy.stub @@ -12,12 +12,12 @@ class {{ class }} public function allowRestify(User $user = null): bool { - // + return true; } public function show(User $user, {{ model }} $model): bool { - // + return true; } public function store(User $user): bool diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 421eda24f..63c3b0ca2 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -473,6 +473,19 @@ function ($items) { } public function resolveShowMeta($request) + { + if ($request->boolean('withMeta')) { + return $this->policyMeta($request); + } + + if (! config('restify.repositories.serialize_show_meta')) { + return null; + } + + return $this->policyMeta($request); + } + + private function policyMeta(Request $request): array { return [ 'authorizedToShow' => $this->authorizedToShow($request), @@ -532,7 +545,15 @@ public function resolveRelationships($request): array */ public function resolveIndexMeta($request) { - return $this->resolveShowMeta($request); + if ($request->boolean('withMeta')) { + return $this->policyMeta($request); + } + + if (! config('restify.repositories.serialize_index_meta')) { + return null; + } + + return $this->policyMeta($request); } /** diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 2f7c6bc8f..c5bc6d49c 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -77,6 +77,8 @@ protected function getEnvironmentSetUp($app): void { config()->set('database.default', 'sqlite'); config()->set('restify.auth.user_model', User::class); + config()->set('restify.repositories.serialize_index_meta', true); + config()->set('restify.repositories.serialize_show_meta', true); $migration = include __DIR__.'/../database/migrations/create_action_logs_table.php.stub'; $migration->up(); diff --git a/tests/Repositories/RepositorySerializerTest.php b/tests/Repositories/RepositorySerializerTest.php index 0145bfa60..945790253 100644 --- a/tests/Repositories/RepositorySerializerTest.php +++ b/tests/Repositories/RepositorySerializerTest.php @@ -38,4 +38,50 @@ public function test_can_manually_serialize_repository(): void ->count('data', 20) ->etc(); } + + public function test_disable_show_meta(): void + { + $posts = PostFactory::many(); + + config()->set('restify.repositories.serialize_show_meta', false); + + $this->getJson(PostRepository::route($posts->first()->id)) + ->assertJson( + fn (AssertableJson $json) => $json + ->missing('data.meta') + ->etc() + ); + + $this->getJson(PostRepository::route($posts->first()->id, [ + 'withMeta' => true, + ])) + ->assertJson( + fn (AssertableJson $json) => $json + ->has('data.meta') + ->etc() + ); + } + + public function test_disable_index_meta(): void + { + PostFactory::many(); + + config()->set('restify.repositories.serialize_index_meta', false); + + $this->getJson(PostRepository::route()) + ->assertJson( + fn (AssertableJson $json) => $json + ->missing('data.0.meta') + ->etc() + ); + + $this->getJson(PostRepository::route(query: [ + 'withMeta' => true, + ])) + ->assertJson( + fn (AssertableJson $json) => $json + ->has('data.0.meta') + ->etc() + ); + } } From 29fb11f8284be50a25a71702d246ddfd27777dbc Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Thu, 21 Jul 2022 16:02:47 +0300 Subject: [PATCH 35/42] fix: formatting (#482) --- .github/workflows/php-cs-fixer.yml | 12 ++--- CHANGELOG.md | 7 +++ composer.json | 6 ++- config/restify.php | 2 +- src/Actions/Action.php | 6 ++- src/Commands/ActionCommand.php | 3 +- src/Commands/GetterCommand.php | 3 +- src/Commands/PolicyCommand.php | 2 +- src/Commands/PublishAuthCommand.php | 47 ++++++++--------- src/Commands/RepositoryCommand.php | 3 +- src/Commands/StoreCommand.php | 3 +- src/Contracts/RestifySearchable.php | 7 +++ src/Eager/RelatedCollection.php | 2 +- src/Events/RestifyBeforeEach.php | 3 +- src/Events/RestifyStarting.php | 3 +- src/Events/UserLoggedIn.php | 2 +- src/Events/UserLogout.php | 2 +- src/Fields/BelongsToMany.php | 1 - src/Fields/Concerns/Attachable.php | 4 +- src/Fields/Field.php | 8 +++ src/Fields/File.php | 14 +++--- src/Fields/HasMany.php | 2 +- src/Filters/SearchableFilter.php | 2 +- src/Getters/Getter.php | 2 +- src/Http/Controllers/Auth/LoginController.php | 1 - .../Controllers/Auth/RegisterController.php | 2 +- .../Auth/ResetPasswordController.php | 1 - .../Controllers/Auth/VerifyController.php | 2 +- src/Http/Controllers/RestController.php | 5 +- src/Http/Controllers/RestResponse.php | 50 +++++++++++++------ src/Http/Middleware/AuthorizeRestify.php | 1 + .../Middleware/RestifySanctumAuthenticate.php | 6 +-- src/Models/ActionLog.php | 3 ++ src/Notifications/VerifyEmail.php | 2 +- .../Concerns/InteractsWithModel.php | 2 +- src/Repositories/Concerns/Testing.php | 4 +- src/Repositories/InteractWithFields.php | 2 +- src/Repositories/Repository.php | 8 +-- src/Repositories/RepositoryCollection.php | 4 +- src/Repositories/ValidatingTrait.php | 1 + src/Restify.php | 2 + src/RestifyApplicationServiceProvider.php | 1 + src/Traits/AuthorizableModels.php | 1 + src/Traits/AuthorizedToSee.php | 4 +- tests/Actions/FieldActionTest.php | 15 +++--- tests/Actions/PerformActionControllerTest.php | 3 +- .../RepositoryDetachControllerTest.php | 6 +-- tests/Feature/ActionLogTest.php | 3 +- tests/Feature/Filters/MatchFilterTest.php | 3 +- tests/Feature/Filters/SortableFilterTest.php | 8 +-- tests/Feature/RepositorySearchServiceTest.php | 6 +-- tests/Fields/FieldTest.php | 37 ++++++++++---- tests/Fields/FileTest.php | 4 +- tests/Fields/MorphOneFieldTest.php | 4 +- tests/Fixtures/Company/CompanyPolicy.php | 31 ++++++------ tests/Fixtures/Company/CompanyRepository.php | 2 +- tests/Fixtures/MailTracking.php | 31 ++++++------ tests/Fixtures/Post/Post.php | 1 + tests/Fixtures/Post/PostPolicy.php | 2 +- tests/Fixtures/User/SampleUser.php | 3 +- tests/Fixtures/User/User.php | 3 +- tests/Fixtures/User/UserController.php | 12 +++-- tests/Fixtures/User/UserPolicy.php | 2 +- tests/Prototypes/Prototypeable.php | 1 - tests/Unit/AdvancedFilterTest.php | 3 +- tests/Unit/MatchableFilterTest.php | 3 +- tests/Unit/RepositoryWithRoutesTest.php | 1 - 67 files changed, 255 insertions(+), 177 deletions(-) diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/php-cs-fixer.yml index 502dbe708..afc18a65f 100644 --- a/.github/workflows/php-cs-fixer.yml +++ b/.github/workflows/php-cs-fixer.yml @@ -1,21 +1,19 @@ -name: Check & fix styling +name: Fix PHP code style issues on: [push] jobs: - php-cs-fixer: + php-code-styling: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: ref: ${{ github.head_ref }} - - name: Run PHP CS Fixer - uses: docker://oskarstark/php-cs-fixer-ga - with: - args: --config=.php-cs-fixer.dist.php --allow-risky=yes + - name: Fix PHP code style issues + uses: aglipanci/laravel-pint-action@0.1.0 - name: Commit changes uses: stefanzweifel/git-auto-commit-action@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index c022a1544..161ab72d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to `laravel-restify` will be documented in this file +## [7.0.0] 2022-07-20 +- Nested relationship with custom columns (ie: `api/restify/company/include=users.posts[id, name].comments[title]`) +- Restify will do not expose data without a defined policy for the resource +- Performance improvements +- + + ## [5.0.0] 2021-05-23 - Repositories CRUD + Bulk - Actions diff --git a/composer.json b/composer.json index 0f14b9f80..26e357981 100644 --- a/composer.json +++ b/composer.json @@ -20,9 +20,10 @@ "require": { "php": "^8.0", "illuminate/contracts": "^9.0", + "laravel/pint": "^1.0", "spatie/data-transfer-object": "^3.1", - "spatie/once": "^3.0", - "spatie/laravel-package-tools": "^1.12" + "spatie/laravel-package-tools": "^1.12", + "spatie/once": "^3.0" }, "require-dev": { "brianium/paratest": "^6.2", @@ -51,6 +52,7 @@ } }, "scripts": { + "format": "vendor/bin/pint", "psalm": "./vendor/bin/psalm --no-cache", "analyse": "vendor/bin/phpstan analyse", "test": "./vendor/bin/testbench package:test --parallel --no-coverage", diff --git a/config/restify.php b/config/restify.php index 37276e0fd..855e9290e 100644 --- a/config/restify.php +++ b/config/restify.php @@ -95,7 +95,7 @@ 'middleware' => [ 'api', -// 'auth.sanctum', + // 'auth.sanctum', DispatchRestifyStartingEvent::class, AuthorizeRestify::class, ], diff --git a/src/Actions/Action.php b/src/Actions/Action.php index 779659e0d..f3b010ab8 100644 --- a/src/Actions/Action.php +++ b/src/Actions/Action.php @@ -23,8 +23,8 @@ /** * Class Action + * * @method JsonResponse handle(Request $request, ?Model|Collection $models = null, ?int $row = null) - * @package Binaryk\LaravelRestify\Actions */ abstract class Action implements JsonSerializable { @@ -56,6 +56,7 @@ abstract class Action implements JsonSerializable /** * Default uri key for the action. + * * @var string */ public static $uriKey; @@ -116,6 +117,7 @@ public function canRun(Closure $callback) * Get the payload available on the action. * * @return array + * * @deprecated Use rules instead */ public function payload(): array @@ -175,7 +177,7 @@ public function handleRequest(ActionRequest $request) Transaction::run(function () use ($models, $request, &$response) { $response = $this->handle($request, $models); - $models->each(function (Model $model) use ($request) { + $models->each(function (Model $model) { // if (in_array(HasActionLogs::class, class_uses_recursive($model), true)) { // Restify::actionLog()::forRepositoryAction($this, $model, $request->user())->save(); // } diff --git a/src/Commands/ActionCommand.php b/src/Commands/ActionCommand.php index 3dc2a5421..4413e0eec 100644 --- a/src/Commands/ActionCommand.php +++ b/src/Commands/ActionCommand.php @@ -27,8 +27,9 @@ public function handle() * Build the class with the given name. * This method should return the file class content. * - * @param string $name + * @param string $name * @return string + * * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException */ protected function buildClass($name) diff --git a/src/Commands/GetterCommand.php b/src/Commands/GetterCommand.php index 089e1459e..b2e4c3831 100644 --- a/src/Commands/GetterCommand.php +++ b/src/Commands/GetterCommand.php @@ -27,8 +27,9 @@ public function handle() * Build the class with the given name. * This method should return the file class content. * - * @param string $name + * @param string $name * @return string + * * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException */ protected function buildClass($name) diff --git a/src/Commands/PolicyCommand.php b/src/Commands/PolicyCommand.php index 4dc4ea2c9..fd941a752 100644 --- a/src/Commands/PolicyCommand.php +++ b/src/Commands/PolicyCommand.php @@ -81,7 +81,7 @@ protected function getStub() /** * Get the default namespace for the class. * - * @param string $rootNamespace + * @param string $rootNamespace * @return string */ protected function getDefaultNamespace($rootNamespace) diff --git a/src/Commands/PublishAuthCommand.php b/src/Commands/PublishAuthCommand.php index 7d417a726..9f34e23ab 100644 --- a/src/Commands/PublishAuthCommand.php +++ b/src/Commands/PublishAuthCommand.php @@ -25,7 +25,6 @@ public function handle() } /** - * * @return $this */ public function publishControllers(): self @@ -41,7 +40,6 @@ public function publishControllers(): self } /** - * * @return $this */ public function publishBlades(): self @@ -57,7 +55,6 @@ public function publishBlades(): self } /** - * * @return $this */ public function publishEmails(): self @@ -73,8 +70,7 @@ public function publishEmails(): self } /** - * - * @param string $path + * @param string $path * @return $this */ public function checkDirectory(string $path): self @@ -87,21 +83,20 @@ public function checkDirectory(string $path): self } /** - * - * @param string $path - * @param string $stubDirectory - * @param string $format + * @param string $path + * @param string $stubDirectory + * @param string $format * @return $this */ protected function copyDirectory(string $path, string $stubDirectory, string $format): self { $filesystem = new Filesystem(); - collect($filesystem->allFiles(__DIR__ . $stubDirectory)) + collect($filesystem->allFiles(__DIR__.$stubDirectory)) ->each(function (SplFileInfo $file) use ($filesystem, $path, $format, $stubDirectory) { $filesystem->copy( $file->getPathname(), - $fullPath = app_path($path . Str::replaceLast('.stub', $format, $file->getFilename())) + $fullPath = app_path($path.Str::replaceLast('.stub', $format, $file->getFilename())) ); $this->setNamespace($stubDirectory, $file->getFilename(), $path, $fullPath); @@ -111,11 +106,10 @@ protected function copyDirectory(string $path, string $stubDirectory, string $fo } /** - * - * @param string $stubDirectory - * @param string $fileName - * @param string $path - * @param string $fullPath + * @param string $stubDirectory + * @param string $fileName + * @param string $path + * @param string $fullPath * @return string */ protected function setNamespace(string $stubDirectory, string $fileName, string $path, string $fullPath): string @@ -124,28 +118,27 @@ protected function setNamespace(string $stubDirectory, string $fileName, string return file_put_contents($fullPath, str_replace( '{{namespace}}', - $this->laravel->getNamespace() . $path, - file_get_contents(__DIR__ . $stubDirectory . '/' . $fileName) + $this->laravel->getNamespace().$path, + file_get_contents(__DIR__.$stubDirectory.'/'.$fileName) )); } /** - * * @return $this */ protected function registerRoutes(): self { $pathProvider = '../routes/api.php'; - $routeStub = __DIR__ . '/stubs/Routes/routes.stub'; + $routeStub = __DIR__.'/stubs/Routes/routes.stub'; file_put_contents(app_path($pathProvider), str_replace( - "use Illuminate\Support\Facades\Route;" . PHP_EOL, - "use App\Http\Controllers\Restify\Auth\RegisterController;" . PHP_EOL . - "use App\Http\Controllers\Restify\Auth\ForgotPasswordController;" . PHP_EOL . - "use App\Http\Controllers\Restify\Auth\LoginController;" . PHP_EOL . - "use App\Http\Controllers\Restify\Auth\ResetPasswordController;" . PHP_EOL . - "use Illuminate\Support\Facades\Route;" . PHP_EOL . - "use App\Http\Controllers\Restify\Auth\VerifyController;" . PHP_EOL, + "use Illuminate\Support\Facades\Route;".PHP_EOL, + "use App\Http\Controllers\Restify\Auth\RegisterController;".PHP_EOL. + "use App\Http\Controllers\Restify\Auth\ForgotPasswordController;".PHP_EOL. + "use App\Http\Controllers\Restify\Auth\LoginController;".PHP_EOL. + "use App\Http\Controllers\Restify\Auth\ResetPasswordController;".PHP_EOL. + "use Illuminate\Support\Facades\Route;".PHP_EOL. + "use App\Http\Controllers\Restify\Auth\VerifyController;".PHP_EOL, file_get_contents(app_path($pathProvider)) )); diff --git a/src/Commands/RepositoryCommand.php b/src/Commands/RepositoryCommand.php index 2af0fab5f..4d757ecd0 100644 --- a/src/Commands/RepositoryCommand.php +++ b/src/Commands/RepositoryCommand.php @@ -55,8 +55,9 @@ public function handle() * Build the class with the given name. * This method should return the file class content. * - * @param string $name + * @param string $name * @return string + * * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException */ protected function buildClass($name) diff --git a/src/Commands/StoreCommand.php b/src/Commands/StoreCommand.php index cd5e18818..f560e0f5e 100644 --- a/src/Commands/StoreCommand.php +++ b/src/Commands/StoreCommand.php @@ -27,8 +27,9 @@ public function handle() * Build the class with the given name. * This method should return the file class content. * - * @param string $name + * @param string $name * @return string + * * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException */ protected function buildClass($name) diff --git a/src/Contracts/RestifySearchable.php b/src/Contracts/RestifySearchable.php index ed63224b0..68202f74b 100644 --- a/src/Contracts/RestifySearchable.php +++ b/src/Contracts/RestifySearchable.php @@ -8,13 +8,19 @@ interface RestifySearchable { public const DEFAULT_PER_PAGE = 15; + public const DEFAULT_RELATABLE_PER_PAGE = 15; public const MATCH_TEXT = 'text'; + public const MATCH_BOOL = 'bool'; + public const MATCH_INTEGER = 'integer'; + public const MATCH_DATETIME = 'datetime'; + public const MATCH_BETWEEN = 'between'; + public const MATCH_ARRAY = 'array'; /** @@ -39,6 +45,7 @@ public static function withs(): array; * * To use this filter we have to send in query: * [ 'match' => [ 'id' => 1 ] ] + * * @return array */ public static function matches(): array; diff --git a/src/Eager/RelatedCollection.php b/src/Eager/RelatedCollection.php index f848cc4f1..6e57b39d2 100644 --- a/src/Eager/RelatedCollection.php +++ b/src/Eager/RelatedCollection.php @@ -100,7 +100,7 @@ public function inRequest(RestifyRequest $request, Repository $repository): self public function mapIntoRelated(RestifyRequest $request, Repository $repository): self { - return $this->map(function ($value, $key) use ($request) { + return $this->map(function ($value, $key) { return tap( Related::make($key, $value instanceof EagerField ? $value : null), function (Related $related) use ($value) { diff --git a/src/Events/RestifyBeforeEach.php b/src/Events/RestifyBeforeEach.php index a2273246d..b0c7970b7 100644 --- a/src/Events/RestifyBeforeEach.php +++ b/src/Events/RestifyBeforeEach.php @@ -15,7 +15,8 @@ class RestifyBeforeEach /** * RestifyAfterEach constructor. - * @param $request + * + * @param $request */ public function __construct($request) { diff --git a/src/Events/RestifyStarting.php b/src/Events/RestifyStarting.php index ac1887ea7..838c16fa5 100644 --- a/src/Events/RestifyStarting.php +++ b/src/Events/RestifyStarting.php @@ -15,7 +15,8 @@ class RestifyStarting /** * RestifyServing constructor. - * @param $request + * + * @param $request */ public function __construct($request) { diff --git a/src/Events/UserLoggedIn.php b/src/Events/UserLoggedIn.php index 410eb1748..33ef6ee01 100644 --- a/src/Events/UserLoggedIn.php +++ b/src/Events/UserLoggedIn.php @@ -15,7 +15,7 @@ class UserLoggedIn public $user; /** - * @param Authenticatable $user + * @param Authenticatable $user */ public function __construct($user) { diff --git a/src/Events/UserLogout.php b/src/Events/UserLogout.php index 29fb9e268..fab53c129 100644 --- a/src/Events/UserLogout.php +++ b/src/Events/UserLogout.php @@ -15,7 +15,7 @@ class UserLogout public $user; /** - * @param Authenticatable $user + * @param Authenticatable $user */ public function __construct($user) { diff --git a/src/Fields/BelongsToMany.php b/src/Fields/BelongsToMany.php index 75af64f91..c8defa5fa 100644 --- a/src/Fields/BelongsToMany.php +++ b/src/Fields/BelongsToMany.php @@ -55,7 +55,6 @@ public function resolve($repository, $attribute = null) $paginator = $paginator->take(request('relatablePerPage') ?? ($repository::$defaultRelatablePerPage ?? RestifySearchable::DEFAULT_RELATABLE_PER_PAGE))->get(); } - $this->value = $paginator->map(function ($item) { try { return $this->repositoryClass::resolveWith($item) diff --git a/src/Fields/Concerns/Attachable.php b/src/Fields/Concerns/Attachable.php index b1c6dc443..ec8998fe3 100644 --- a/src/Fields/Concerns/Attachable.php +++ b/src/Fields/Concerns/Attachable.php @@ -43,7 +43,7 @@ public function canAttach(callable|Closure $callback) } /** - * @param Closure $callback + * @param Closure $callback * @return $this */ public function canDetach(callable|Closure $callback) @@ -137,7 +137,7 @@ public function initializePivot(RestifyRequest $request, $relationship, $related /** * Set the columns on the pivot table to retrieve. * - * @param array|mixed $fields + * @param array|mixed $fields * @return $this */ public function withPivot($fields) diff --git a/src/Fields/Field.php b/src/Fields/Field.php index dc70f7a86..9394469e0 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -30,6 +30,7 @@ class Field extends OrganicField implements JsonSerializable /** * Column name of the field. + * * @var string|callable|null */ public $attribute; @@ -43,6 +44,7 @@ class Field extends OrganicField implements JsonSerializable /** * In case of the update, this will keep the previous value. + * * @var */ public $valueBeforeUpdate; @@ -61,18 +63,21 @@ class Field extends OrganicField implements JsonSerializable /** * Callback called when the value is filled, this callback will do not override the fill action. + * * @var Closure */ public $storeCallback; /** * Callback called when the value is filled from a store bulk, this callback will do not override the fill action. + * * @var Closure */ public $storeBulkCallback; /** * Callback called when update. + * * @var Closure */ public $updateCallback; @@ -335,6 +340,7 @@ public function getAttribute() /** * Validation rules for store. + * * @param $rules * @return Field */ @@ -385,6 +391,7 @@ public function updatingRules($rules) /** * Validation rules for store. + * * @param $rules * @return Field */ @@ -677,6 +684,7 @@ public function value($value) /** * @param $value * @return $this + * * @deprecated */ public function append($value) diff --git a/src/Fields/File.php b/src/Fields/File.php index 8033dcc39..8cfba08e4 100644 --- a/src/Fields/File.php +++ b/src/Fields/File.php @@ -65,7 +65,7 @@ public function __construct($attribute, callable $resolveCallback = null) /** * Specify the callback or the name that should be used to determine the file's storage name. * - * @param callable|string $storeAsCallback + * @param callable|string $storeAsCallback * @return $this */ public function storeAs($storeAs): self @@ -78,7 +78,7 @@ public function storeAs($storeAs): self /** * Prepare the storage callback. * - * @param callable|null $storageCallback + * @param callable|null $storageCallback * @return void */ protected function prepareStorageCallback(callable $storageCallback = null): void @@ -93,7 +93,7 @@ protected function prepareStorageCallback(callable $storageCallback = null): voi /** * Specify the column where the file's original name should be stored. * - * @param string $column + * @param string $column * @return $this */ public function storeOriginalName($column) @@ -106,7 +106,7 @@ public function storeOriginalName($column) /** * Specify the column where the file size should be stored. * - * @param string $column + * @param string $column * @return $this */ public function storeSize($column) @@ -132,7 +132,7 @@ protected function storeFile(Request $request, string $requestAttribute) /** * Specify the callback that should be used to store the file. * - * @param callable|Storable $storageCallback + * @param callable|Storable $storageCallback * @return $this */ public function store($storageCallback): self @@ -147,8 +147,8 @@ public function store($storageCallback): self /** * Merge the specified extra file information columns into the storable attributes. * - * @param \Illuminate\Http\Request $request - * @param array $attributes + * @param \Illuminate\Http\Request $request + * @param array $attributes * @return array */ protected function mergeExtraStorageColumns($request, array $attributes): array diff --git a/src/Fields/HasMany.php b/src/Fields/HasMany.php index bb9ca1e1d..fcc0c6a78 100644 --- a/src/Fields/HasMany.php +++ b/src/Fields/HasMany.php @@ -27,7 +27,7 @@ public function __construct($relation, $parentRepository) } /** - * @param Repository $repository + * @param Repository $repository * @param null $attribute * @return $this|EagerField|HasMany */ diff --git a/src/Filters/SearchableFilter.php b/src/Filters/SearchableFilter.php index 0ef4c9e92..29985c74a 100644 --- a/src/Filters/SearchableFilter.php +++ b/src/Filters/SearchableFilter.php @@ -42,7 +42,7 @@ public function filter(RestifyRequest $request, $query, $value) } if (! config('restify.search.case_sensitive')) { - return $query->orWhereRaw("UPPER({$this->column}) LIKE '%". strtoupper($value)."%'"); + return $query->orWhereRaw("UPPER({$this->column}) LIKE '%".strtoupper($value)."%'"); } return $query->orWhere($this->column, $likeOperator, '%'.$value.'%'); diff --git a/src/Getters/Getter.php b/src/Getters/Getter.php index 1b1479ce4..3cf3c918d 100644 --- a/src/Getters/Getter.php +++ b/src/Getters/Getter.php @@ -25,8 +25,8 @@ /** * Class Getter + * * @method Response|JsonResponse handle(RestifyRequest $request, ?Model $model = null) - * @package Binaryk\LaravelRestify\Getters */ abstract class Getter implements JsonSerializable { diff --git a/src/Http/Controllers/Auth/LoginController.php b/src/Http/Controllers/Auth/LoginController.php index 66b72bf09..51f633649 100644 --- a/src/Http/Controllers/Auth/LoginController.php +++ b/src/Http/Controllers/Auth/LoginController.php @@ -18,7 +18,6 @@ public function __invoke(Request $request): JsonResponse ]); /** * @var User $user */ - if (! $user = config('restify.auth.user_model')::query() ->whereEmail($request->input('email')) ->first()) { diff --git a/src/Http/Controllers/Auth/RegisterController.php b/src/Http/Controllers/Auth/RegisterController.php index 522a2eac0..39619a6a2 100644 --- a/src/Http/Controllers/Auth/RegisterController.php +++ b/src/Http/Controllers/Auth/RegisterController.php @@ -13,7 +13,7 @@ class RegisterController extends Controller public function __invoke(Request $request): JsonResponse { $request->validate([ - 'email' => ['required', 'email', 'max:255', 'unique:' . Config::get('config.auth.table', 'users')], + 'email' => ['required', 'email', 'max:255', 'unique:'.Config::get('config.auth.table', 'users')], 'password' => ['required', 'confirmed'], ]); diff --git a/src/Http/Controllers/Auth/ResetPasswordController.php b/src/Http/Controllers/Auth/ResetPasswordController.php index f48427ef6..638832667 100644 --- a/src/Http/Controllers/Auth/ResetPasswordController.php +++ b/src/Http/Controllers/Auth/ResetPasswordController.php @@ -20,7 +20,6 @@ public function __invoke(Request $request): JsonResponse ]); /** * @var User $user */ - $user = config('config.auth.user_model')::query()->where($request->only('email'))->firstOrFail(); if (! Password::getRepository()->exists($user, $request->input('token'))) { diff --git a/src/Http/Controllers/Auth/VerifyController.php b/src/Http/Controllers/Auth/VerifyController.php index 6f55f48fb..c6c05d38d 100644 --- a/src/Http/Controllers/Auth/VerifyController.php +++ b/src/Http/Controllers/Auth/VerifyController.php @@ -15,7 +15,7 @@ public function __invoke(int $id, string $hash) { $user = User::query()->findOrFail($id); - if ($user instanceof Sanctumable && ! hash_equals((string)$hash, sha1($user->getEmailForVerification()))) { + if ($user instanceof Sanctumable && ! hash_equals((string) $hash, sha1($user->getEmailForVerification()))) { throw new AuthorizationException('Invalid hash'); } diff --git a/src/Http/Controllers/RestController.php b/src/Http/Controllers/RestController.php index 5a790efc4..dca8ba428 100644 --- a/src/Http/Controllers/RestController.php +++ b/src/Http/Controllers/RestController.php @@ -58,6 +58,7 @@ abstract class RestController extends BaseController /** * @return RestifyRequest + * * @throws BindingResolutionException */ public function request() @@ -73,6 +74,7 @@ public function request() /** * @return Config + * * @throws BindingResolutionException */ public function config() @@ -126,8 +128,9 @@ public function message($msg): RestResponse /** * Returns with a list of errors. * - * @param array $errors + * @param array $errors * @return JsonResponse + * * @throws BindingResolutionException */ protected function errors(array $errors) diff --git a/src/Http/Controllers/RestResponse.php b/src/Http/Controllers/RestResponse.php index 2e7df52ee..9ef1bc5b1 100644 --- a/src/Http/Controllers/RestResponse.php +++ b/src/Http/Controllers/RestResponse.php @@ -51,19 +51,33 @@ class RestResponse extends JsonResponse implements Responsable * Response Codes. */ public const REST_RESPONSE_AUTH_CODE = 401; + public const REST_RESPONSE_REFRESH_CODE = 103; + public const REST_RESPONSE_CREATED_CODE = 201; + public const REST_RESPONSE_UPDATED_CODE = 200; + public const REST_RESPONSE_DELETED_CODE = 204; // update or delete with success + public const REST_RESPONSE_BLANK_CODE = 204; + public const REST_RESPONSE_ERROR_CODE = 500; + public const REST_RESPONSE_INVALID_CODE = 400; + public const REST_RESPONSE_UNAUTHORIZED_CODE = 401; + public const REST_RESPONSE_FORBIDDEN_CODE = 403; + public const REST_RESPONSE_MISSING_CODE = 404; + public const REST_RESPONSE_NOTFOUND_CODE = 404; + public const REST_RESPONSE_THROTTLE_CODE = 429; + public const REST_RESPONSE_SUCCESS_CODE = 200; + public const REST_RESPONSE_UNAVAILABLE_CODE = 503; public const CODES = [ @@ -96,12 +110,14 @@ class RestResponse extends JsonResponse implements Responsable /** * Where specified, a meta member can be used to include non-standard meta-information. * The value of each meta member MUST be an object (a “meta object”). + * * @var array */ protected ?array $meta = null; /** * A links object containing links related to the resource. + * * @var array */ protected array $links; @@ -125,12 +141,14 @@ class RestResponse extends JsonResponse implements Responsable /** * Model related entities. + * * @var */ protected $relationships; /** * Indicate if response could include sensitive information (file, line). + * * @var bool */ public bool $debug = false; @@ -151,7 +169,7 @@ public function data($data = null): self /** * Set response errors. * - * @param mixed $errors + * @param mixed $errors * @return $this|null */ public function errors($errors): self @@ -164,7 +182,7 @@ public function errors($errors): self /** * Add error to response errors. * - * @param mixed $message + * @param mixed $message * @return $this */ public function addError($message): self @@ -205,7 +223,7 @@ public function stack(string $stack): self /** * Magic to get response code constants. * - * @param string $key + * @param string $key * @return mixed|null */ public function __get($key) @@ -241,8 +259,7 @@ public function __call($func, $args) /** * Build a new response with our response data. * - * @param mixed $response - * + * @param mixed $response * @return JsonResponse */ public function respond($response = null): JsonResponse @@ -334,6 +351,7 @@ public function setLink($name, $value): self /** * Set message on response. + * * @param $message * @return RestResponse */ @@ -356,7 +374,7 @@ public function getAttribute($name) /** * Set attributes at root level. * - * @param array $attributes + * @param array $attributes * @return mixed */ public function setAttributes(array $attributes) @@ -390,8 +408,8 @@ public function type(string $type): self * Useful when newly created repository, will prepare the response according * with JSON:API https://jsonapi.org/format/#document-resource-object-fields. * - * @param Repository $repository - * @param bool $withRelations + * @param Repository $repository + * @param bool $withRelations * @return $this */ public function forRepository(Repository $repository, $withRelations = false): self @@ -439,7 +457,7 @@ public static function beforeRespond($response) } /** - * @param \Illuminate\Http\Request $request + * @param \Illuminate\Http\Request $request * @return JsonResponse|\Symfony\Component\HttpFoundation\Response */ public function toResponse($request = null) @@ -507,7 +525,7 @@ public function getErrors(): ?array } /** - * @param Throwable $exception + * @param Throwable $exception * @param $condition * @return $this */ @@ -529,7 +547,7 @@ public function dump(Throwable $exception, $condition) /** * Debug the log if the environment is local. * - * @param Throwable $exception + * @param Throwable $exception * @return $this */ public function dumpLocal(Throwable $exception): self @@ -542,7 +560,7 @@ public function dumpLocal(Throwable $exception): self * * $this->model( User::find(1) ) * - * @param Model $model + * @param Model $model * @return $this */ public function model(Model $model): self @@ -563,10 +581,10 @@ public static function index(AbstractPaginator|Paginator $paginator, array $meta { return response()->json( [ - 'meta' => array_merge(RepositoryCollection::meta($paginator->toArray()), $meta), - 'links' => RepositoryCollection::paginationLinks($paginator->toArray()), - 'data' => $paginator->getCollection(), - ] + 'meta' => array_merge(RepositoryCollection::meta($paginator->toArray()), $meta), + 'links' => RepositoryCollection::paginationLinks($paginator->toArray()), + 'data' => $paginator->getCollection(), + ] ); } } diff --git a/src/Http/Middleware/AuthorizeRestify.php b/src/Http/Middleware/AuthorizeRestify.php index 40539f602..c40b00a31 100644 --- a/src/Http/Middleware/AuthorizeRestify.php +++ b/src/Http/Middleware/AuthorizeRestify.php @@ -13,6 +13,7 @@ class AuthorizeRestify * @param \Illuminate\Http\Request $request * @param \Closure $next * @return \Illuminate\Http\Response + * * @throws UnauthorizedException */ public function handle($request, $next) diff --git a/src/Http/Middleware/RestifySanctumAuthenticate.php b/src/Http/Middleware/RestifySanctumAuthenticate.php index 6ead5c382..a402d8964 100644 --- a/src/Http/Middleware/RestifySanctumAuthenticate.php +++ b/src/Http/Middleware/RestifySanctumAuthenticate.php @@ -11,9 +11,9 @@ class RestifySanctumAuthenticate extends BaseAuthenticationMiddleware /** * Handle an incoming request. * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @param string[] ...$guards + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @param string[] ...$guards * @return mixed * * @throws \Illuminate\Auth\AuthenticationException diff --git a/src/Models/ActionLog.php b/src/Models/ActionLog.php index 924702d6e..eb33015ed 100644 --- a/src/Models/ActionLog.php +++ b/src/Models/ActionLog.php @@ -9,6 +9,7 @@ /** * Class ActionLog. + * * @property int $id * @property string $batch_id * @property string $user_id @@ -41,7 +42,9 @@ class ActionLog extends Model public const STATUS_FINISHED = 'finished'; public const ACTION_CREATED = 'Stored'; + public const ACTION_UPDATED = 'Updated'; + public const ACTION_DELETED = 'Deleted'; public static function forRepositoryStored(Model $model, Authenticatable $user = null, array $dirty = null): self diff --git a/src/Notifications/VerifyEmail.php b/src/Notifications/VerifyEmail.php index 35d32f750..33580135f 100644 --- a/src/Notifications/VerifyEmail.php +++ b/src/Notifications/VerifyEmail.php @@ -9,7 +9,7 @@ class VerifyEmail extends VerifyEmailLaravel /** * Get the verification URL for the given notifiable. * - * @param mixed $notifiable + * @param mixed $notifiable * @return string */ protected function verificationUrl($notifiable) diff --git a/src/Repositories/Concerns/InteractsWithModel.php b/src/Repositories/Concerns/InteractsWithModel.php index dd6410cff..8b98a8438 100644 --- a/src/Repositories/Concerns/InteractsWithModel.php +++ b/src/Repositories/Concerns/InteractsWithModel.php @@ -9,8 +9,8 @@ /** * Trait InteractsWithModel + * * @mixin Repository - * @package Binaryk\LaravelRestify\Repositories\Concerns */ trait InteractsWithModel { diff --git a/src/Repositories/Concerns/Testing.php b/src/Repositories/Concerns/Testing.php index b03238517..f9e9a4d27 100644 --- a/src/Repositories/Concerns/Testing.php +++ b/src/Repositories/Concerns/Testing.php @@ -12,8 +12,6 @@ * Trait Testing * * @mixin Repository - * - * @package Binaryk\LaravelRestify\Repositories\Concerns */ trait Testing { @@ -23,7 +21,7 @@ public static function route(string $path = null, array $query = []): Stringable $path = str($path)->replaceFirst('/', '')->toString(); } - $base = (static::prefix() ?: Str::replaceFirst('//', '/', Restify::path())) .'/'.static::uriKey(); + $base = (static::prefix() ?: Str::replaceFirst('//', '/', Restify::path())).'/'.static::uriKey(); $route = $path ? $base.'/'.$path diff --git a/src/Repositories/InteractWithFields.php b/src/Repositories/InteractWithFields.php index 6402c489a..abbe10ea2 100644 --- a/src/Repositories/InteractWithFields.php +++ b/src/Repositories/InteractWithFields.php @@ -9,7 +9,7 @@ trait InteractWithFields /** * Resolvable attributes. * - * @param RestifyRequest $request + * @param RestifyRequest $request * @return array */ public function fields(RestifyRequest $request): array diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 63c3b0ca2..278da5769 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -68,6 +68,7 @@ class Repository implements RestifySearchable, JsonSerializable * The list of relations available for the show or index. * * e.g. ?related=users + * * @var array */ public static array $related; @@ -579,6 +580,7 @@ public function index(RestifyRequest $request) /** * * Apply search, match, sort, related. + * * @var LengthAwarePaginator $paginator */ $paginator = RepositorySearchService::make()->search($request, $this) @@ -590,7 +592,6 @@ public function index(RestifyRequest $request) return $repository->authorizedToShow($request); })->values(); - $data = $items->map(fn (self $repository) => $repository->serializeForIndex($request)); return response()->json($this->filter([ @@ -864,7 +865,7 @@ public function detach(RestifyRequest $request, $repositoryId, Collection $pivot public function destroy(RestifyRequest $request, $repositoryId) { - $status = DB::transaction(function () use ($request) { + $status = DB::transaction(function () { return $this->resource->delete(); }); @@ -963,6 +964,7 @@ public function allowToDestroy(RestifyRequest $request) /** * @param $request * @return $this + * * @throws \Illuminate\Auth\Access\AuthorizationException */ public function allowToShow($request): self @@ -1139,6 +1141,6 @@ public static function usesScout(): bool public static function serializer(): Serializer { - return (new Serializer(app(static::class))); + return new Serializer(app(static::class)); } } diff --git a/src/Repositories/RepositoryCollection.php b/src/Repositories/RepositoryCollection.php index e9137bdc0..cdb0fb129 100644 --- a/src/Repositories/RepositoryCollection.php +++ b/src/Repositories/RepositoryCollection.php @@ -9,7 +9,7 @@ class RepositoryCollection /** * Get the pagination links for the response. * - * @param array $paginated + * @param array $paginated * @return array */ public static function paginationLinks($paginated) @@ -29,7 +29,7 @@ public static function paginationLinks($paginated) /** * Gather the meta data for the response. * - * @param array $paginated + * @param array $paginated * @return array */ public static function meta($paginated) diff --git a/src/Repositories/ValidatingTrait.php b/src/Repositories/ValidatingTrait.php index 4ffb91106..34dc173c2 100644 --- a/src/Repositories/ValidatingTrait.php +++ b/src/Repositories/ValidatingTrait.php @@ -75,6 +75,7 @@ public static function validatorForStoringBulk(RestifyRequest $request, array $p /** * Validate a resource update request. + * * @param RestifyRequest $request * @param null $resource */ diff --git a/src/Restify.php b/src/Restify.php index b7e7f5b4c..504076afc 100644 --- a/src/Restify.php +++ b/src/Restify.php @@ -79,6 +79,7 @@ public static function repositoryClassForPrefix(string $prefix): ?string * * @param string $key * @throw RepositoryNotFoundException + * * @return Repository */ public static function repository(string $key): Repository @@ -149,6 +150,7 @@ public static function repositories(array $repositories) * * @param string $directory * @return void + * * @throws ReflectionException */ public static function repositoriesFrom(string $directory): void diff --git a/src/RestifyApplicationServiceProvider.php b/src/RestifyApplicationServiceProvider.php index d86ce4fb0..f8bca8837 100644 --- a/src/RestifyApplicationServiceProvider.php +++ b/src/RestifyApplicationServiceProvider.php @@ -29,6 +29,7 @@ public function boot() * Register the application's Rest resources. * * @return void + * * @throws ReflectionException */ protected function repositories(): void diff --git a/src/Traits/AuthorizableModels.php b/src/Traits/AuthorizableModels.php index e0730a712..9bae0d103 100644 --- a/src/Traits/AuthorizableModels.php +++ b/src/Traits/AuthorizableModels.php @@ -12,6 +12,7 @@ * Could be used as a trait in a model class and in a repository class. * * @property Model $resource + * * @author Eduard Lupacescu */ trait AuthorizableModels diff --git a/src/Traits/AuthorizedToSee.php b/src/Traits/AuthorizedToSee.php index 92578d0b3..b00fe47fc 100644 --- a/src/Traits/AuthorizedToSee.php +++ b/src/Traits/AuthorizedToSee.php @@ -17,7 +17,7 @@ trait AuthorizedToSee /** * Determine if the filter or action should be available for the given request. * - * @param Request $request + * @param Request $request * @return bool */ public function authorizedToSee(Request $request) @@ -28,7 +28,7 @@ public function authorizedToSee(Request $request) /** * Set the callback to be run to authorize viewing the filter or action. * - * @param Closure $callback + * @param Closure $callback * @return self */ public function canSee(Closure $callback) diff --git a/tests/Actions/FieldActionTest.php b/tests/Actions/FieldActionTest.php index 6a6f6357d..b2b288e04 100644 --- a/tests/Actions/FieldActionTest.php +++ b/tests/Actions/FieldActionTest.php @@ -15,7 +15,8 @@ class FieldActionTest extends IntegrationTest /** * @test */ public function can_use_actionable_field(): void { - $action = new class () extends Action { + $action = new class() extends Action + { public bool $showOnShow = true; public function handle(RestifyRequest $request, Post $post) @@ -38,9 +39,9 @@ public function handle(RestifyRequest $request, Post $post) $this ->postJson(PostRepository::route(), [ - 'description' => 'Description', - 'title' => $updated = 'Title', - ]) + 'description' => 'Description', + 'title' => $updated = 'Title', + ]) ->assertCreated() ->assertJson( fn (AssertableJson $json) => $json @@ -53,7 +54,8 @@ public function handle(RestifyRequest $request, Post $post) /** @test */ public function can_use_actionable_field_on_bulk_store(): void { - $action = new class () extends Action { + $action = new class() extends Action + { public bool $showOnShow = true; public function handle(RestifyRequest $request, Post $post, int $row) @@ -99,7 +101,8 @@ public function handle(RestifyRequest $request, Post $post, int $row) /** @test */ public function can_use_actionable_field_on_bulk_update(): void { - $action = new class () extends Action { + $action = new class() extends Action + { public bool $showOnShow = true; public function handle(RestifyRequest $request, Post $post, int $row) diff --git a/tests/Actions/PerformActionControllerTest.php b/tests/Actions/PerformActionControllerTest.php index c9f640034..9a40efde0 100644 --- a/tests/Actions/PerformActionControllerTest.php +++ b/tests/Actions/PerformActionControllerTest.php @@ -47,7 +47,8 @@ public function test_could_perform_action_using_all(): void PostRepository::partialMock() ->shouldReceive('actions') ->andReturn([ - new class () extends Action { + new class() extends Action + { public static $uriKey = 'publish'; public function handle(Request $request, Collection $collection) diff --git a/tests/Controllers/RepositoryDetachControllerTest.php b/tests/Controllers/RepositoryDetachControllerTest.php index 99cea309b..079b90656 100644 --- a/tests/Controllers/RepositoryDetachControllerTest.php +++ b/tests/Controllers/RepositoryDetachControllerTest.php @@ -67,7 +67,7 @@ public function test_many_to_many_field_can_intercept_detach_authorization() CompanyRepository::partialMock() ->shouldReceive('include') ->andReturn([ - 'users' => BelongsToMany::make('users', UserRepository::class)->canDetach(function ($request, $pivot) { + 'users' => BelongsToMany::make('users', UserRepository::class)->canDetach(function ($request, $pivot) { $this->assertInstanceOf(Request::class, $request); $this->assertInstanceOf(Pivot::class, $pivot); @@ -89,7 +89,7 @@ public function test_many_to_many_field_can_intercept_detach_method() CompanyRepository::partialMock() ->shouldReceive('include') ->andReturn([ - 'users' => BelongsToMany::make('users', UserRepository::class)->detachCallback(function ($request, $repository, $model) { + 'users' => BelongsToMany::make('users', UserRepository::class)->detachCallback(function ($request, $repository, $model) { $this->assertInstanceOf(Request::class, $request); $this->assertInstanceOf(CompanyRepository::class, $repository); $this->assertInstanceOf(Company::class, $model); @@ -116,7 +116,7 @@ public function test_repository_can_intercept_detach() $mock = CompanyRepository::partialMock(); $mock->shouldReceive('include') ->andReturn([ - 'users' => BelongsToMany::make('users', UserRepository::class), + 'users' => BelongsToMany::make('users', UserRepository::class), ]); CompanyRepository::$detachers = [ diff --git a/tests/Feature/ActionLogTest.php b/tests/Feature/ActionLogTest.php index b26ace009..52cae5d2c 100644 --- a/tests/Feature/ActionLogTest.php +++ b/tests/Feature/ActionLogTest.php @@ -105,7 +105,8 @@ public function test_can_create_log_for_repository_custom_action(): void $user = User::factory()->create(); - $action = new class () extends Action { + $action = new class() extends Action + { public static $uriKey = 'test action'; }; diff --git a/tests/Feature/Filters/MatchFilterTest.php b/tests/Feature/Filters/MatchFilterTest.php index b0808e9b5..74d9b320d 100644 --- a/tests/Feature/Filters/MatchFilterTest.php +++ b/tests/Feature/Filters/MatchFilterTest.php @@ -16,7 +16,8 @@ class MatchFilterTest extends IntegrationTest { public function test_matchable_filter_has_key(): void { - $filter = new class () extends MatchFilter { + $filter = new class() extends MatchFilter + { public ?string $column = 'approved_at'; }; diff --git a/tests/Feature/Filters/SortableFilterTest.php b/tests/Feature/Filters/SortableFilterTest.php index 54a4ad01e..9b7fa5ef5 100644 --- a/tests/Feature/Filters/SortableFilterTest.php +++ b/tests/Feature/Filters/SortableFilterTest.php @@ -23,14 +23,14 @@ public function test_can_order_using_filter_sortable_definition(): void 'name' => SortableFilter::make()->setColumn('name'), ]; - $this->assertSame('Alisa', $this->getJson(UserRepository::route(query: ['sort' => 'name',])) + $this->assertSame('Alisa', $this->getJson(UserRepository::route(query: ['sort' => 'name'])) ->json('data.0.attributes.name')); - $this->assertSame('Zoro', $this->getJson(UserRepository::route(query: ['sort' => 'name',])) + $this->assertSame('Zoro', $this->getJson(UserRepository::route(query: ['sort' => 'name'])) ->json('data.1.attributes.name')); - $this->assertSame('Zoro', $this->getJson(UserRepository::route(query: ['sort' => '-name',])) + $this->assertSame('Zoro', $this->getJson(UserRepository::route(query: ['sort' => '-name'])) ->json('data.0.attributes.name')); - $this->assertSame('Alisa', $this->getJson(UserRepository::route(query: ['sort' => '-name',])) + $this->assertSame('Alisa', $this->getJson(UserRepository::route(query: ['sort' => '-name'])) ->json('data.1.attributes.name')); } } diff --git a/tests/Feature/RepositorySearchServiceTest.php b/tests/Feature/RepositorySearchServiceTest.php index e7571e817..f885354d1 100644 --- a/tests/Feature/RepositorySearchServiceTest.php +++ b/tests/Feature/RepositorySearchServiceTest.php @@ -28,7 +28,7 @@ public function test_can_search_using_filter_searchable_definition(): void 'name' => CustomSearchableFilter::make(), ]; - $this->getJson(UserRepository::route(query: ['search' => 'John',]))->assertJsonCount(4, 'data'); + $this->getJson(UserRepository::route(query: ['search' => 'John']))->assertJsonCount(4, 'data'); } public function test_can_search_incase_sensitive(): void @@ -47,7 +47,7 @@ public function test_can_search_incase_sensitive(): void 'name', ]; - $this->getJson(UserRepository::route(query: ['search' => 'John',]))->assertJsonCount(4, 'data'); + $this->getJson(UserRepository::route(query: ['search' => 'John']))->assertJsonCount(4, 'data'); } public function test_can_search_using_belongs_to_field(): void @@ -69,7 +69,7 @@ public function test_can_search_using_belongs_to_field(): void ]); PostRepository::$related = [ - 'user' => BelongsTo::make('user', UserRepository::class)->searchable([ + 'user' => BelongsTo::make('user', UserRepository::class)->searchable([ 'users.name', ]), ]; diff --git a/tests/Fields/FieldTest.php b/tests/Fields/FieldTest.php index a0137510f..db90c0cae 100644 --- a/tests/Fields/FieldTest.php +++ b/tests/Fields/FieldTest.php @@ -95,7 +95,8 @@ public function test_field_can_have_custom_store_callback(): void 'title' => 'Request value.', ]); - $model = new class () extends Model { + $model = new class() extends Model + { protected $fillable = ['title']; }; @@ -113,7 +114,8 @@ public function test_field_keep_its_value_if_request_empty(): void { $request = new RepositoryStoreRequest([], []); - $model = new class () extends Model { + $model = new class() extends Model + { protected $fillable = ['title']; }; @@ -134,7 +136,8 @@ public function test_field_can_have_custom_update_callback(): void 'title' => 'Request title.', ]); - $model = new class () extends Model { + $model = new class() extends Model + { protected $fillable = ['title']; }; @@ -151,7 +154,8 @@ public function test_field_fill_callback_has_high_priority(): void { $request = new RepositoryStoreRequest([], []); - $model = new class () extends Model { + $model = new class() extends Model + { protected $fillable = ['title']; }; @@ -189,7 +193,8 @@ public function test_field_fill_from_request() 'title' => 'title from request', ]); - $model = new class () extends Model { + $model = new class() extends Model + { protected $fillable = ['title']; }; @@ -217,7 +222,8 @@ public function test_append_overwrite_the_request_value() 'title' => 'title from request', ]); - $model = new class () extends Model { + $model = new class() extends Model + { protected $fillable = ['title']; }; @@ -245,8 +251,10 @@ public function test_field_after_store_called(): void 'title' => 'After store title', ]); - $model = new class () extends Model { + $model = new class() extends Model + { protected $table = 'posts'; + protected $fillable = ['title']; }; @@ -262,8 +270,10 @@ public function test_field_after_store_called(): void public function test_field_after_update_called() { - $model = new class () extends Model { + $model = new class() extends Model + { protected $table = 'posts'; + protected $fillable = ['title']; }; @@ -324,7 +334,8 @@ public function test_fill_field_using_label_key() 'custom_title' => 'title from request', ]); - $model = new class () extends Model { + $model = new class() extends Model + { protected $fillable = ['title']; }; @@ -344,8 +355,10 @@ public function test_field_can_be_filled_from_the_append_value() 'title' => 'Title from the request.', ]); - $model = new class () extends Model { + $model = new class() extends Model + { protected $table = 'posts'; + protected $fillable = ['title']; }; @@ -374,8 +387,10 @@ public function test_field_can_be_filled_from_the_append_callback() 'title' => 'Title from the request.', ]); - $model = new class () extends Model { + $model = new class() extends Model + { protected $table = 'posts'; + protected $fillable = ['title']; }; diff --git a/tests/Fields/FileTest.php b/tests/Fields/FileTest.php index e26464c09..fef3c1d32 100644 --- a/tests/Fields/FileTest.php +++ b/tests/Fields/FileTest.php @@ -139,7 +139,7 @@ public function test_deletable_file_could_be_deleted(): void Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg')->deletable(true), ]); - $this->deleteJson(UserRepository::route($user->getKey(). '/field/avatar')) + $this->deleteJson(UserRepository::route($user->getKey().'/field/avatar')) ->assertNoContent(); Storage::disk('customDisk')->assertMissing('avatar.jpg'); @@ -162,7 +162,7 @@ public function test_not_deletable_file_cannot_be_deleted(): void Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg')->deletable(false), ]); - $this->deleteJson(UserRepository::route($user->getKey(). '/field/avatar')) + $this->deleteJson(UserRepository::route($user->getKey().'/field/avatar')) ->assertNotFound(); } diff --git a/tests/Fields/MorphOneFieldTest.php b/tests/Fields/MorphOneFieldTest.php index 300d79cae..ebcd87de2 100644 --- a/tests/Fields/MorphOneFieldTest.php +++ b/tests/Fields/MorphOneFieldTest.php @@ -68,7 +68,7 @@ class PostWithMorphOneRepository extends Repository public static function include(): array { return [ - 'user' => BelongsTo::make('user', UserRepository::class), + 'user' => BelongsTo::make('user', UserRepository::class), ]; } @@ -77,7 +77,7 @@ public function fields(RestifyRequest $request): array return [ field('title'), - MorphOne::make('user', UserRepository::class), + MorphOne::make('user', UserRepository::class), ]; } } diff --git a/tests/Fixtures/Company/CompanyPolicy.php b/tests/Fixtures/Company/CompanyPolicy.php index 0b1b6a0f4..73f2baa64 100644 --- a/tests/Fixtures/Company/CompanyPolicy.php +++ b/tests/Fixtures/Company/CompanyPolicy.php @@ -12,7 +12,8 @@ class CompanyPolicy /** * Determine whether the user can use restify feature for each CRUD operation. * So if this is not allowed, all operations will be disabled. - * @param User $user + * + * @param User $user * @return mixed */ public function allowRestify(User $user = null) @@ -23,8 +24,8 @@ public function allowRestify(User $user = null) /** * Determine whether the user can get the model. * - * @param User $user - * @param Company $model + * @param User $user + * @param Company $model * @return mixed */ public function show(User $user, Company $model) @@ -35,7 +36,7 @@ public function show(User $user, Company $model) /** * Determine whether the user can create models. * - * @param User $user + * @param User $user * @return mixed */ public function store(User $user) @@ -46,7 +47,7 @@ public function store(User $user) /** * Determine whether the user can create multiple models at once. * - * @param User $user + * @param User $user * @return mixed */ public function storeBulk(User $user) @@ -57,8 +58,8 @@ public function storeBulk(User $user) /** * Determine whether the user can update the model. * - * @param User $user - * @param Company $model + * @param User $user + * @param Company $model * @return mixed */ public function update(User $user, Company $model) @@ -69,8 +70,8 @@ public function update(User $user, Company $model) /** * Determine whether the user can update bulk the model. * - * @param User $user - * @param Company $model + * @param User $user + * @param Company $model * @return mixed */ public function updateBulk(User $user, Company $model) @@ -81,8 +82,8 @@ public function updateBulk(User $user, Company $model) /** * Determine whether the user can delete the model. * - * @param User $user - * @param Company $model + * @param User $user + * @param Company $model * @return mixed */ public function delete(User $user, Company $model) @@ -93,8 +94,8 @@ public function delete(User $user, Company $model) /** * Determine whether the user can restore the model. * - * @param User $user - * @param Company $model + * @param User $user + * @param Company $model * @return mixed */ public function restore(User $user, Company $model) @@ -105,8 +106,8 @@ public function restore(User $user, Company $model) /** * Determine whether the user can permanently delete the model. * - * @param User $user - * @param Company $model + * @param User $user + * @param Company $model * @return mixed */ public function forceDelete(User $user, Company $model) diff --git a/tests/Fixtures/Company/CompanyRepository.php b/tests/Fixtures/Company/CompanyRepository.php index 5c0371aa4..b064c7489 100644 --- a/tests/Fixtures/Company/CompanyRepository.php +++ b/tests/Fixtures/Company/CompanyRepository.php @@ -15,7 +15,7 @@ class CompanyRepository extends Repository public static function include(): array { return [ - 'users' => BelongsToMany::make('users', UserRepository::class)->withPivot( + 'users' => BelongsToMany::make('users', UserRepository::class)->withPivot( Field::make('is_admin')->rules('required') )->canDetach(fn ($request, $pivot) => isset($_SERVER['roles.canDetach.users']) && $_SERVER['roles.canDetach.users']), ]; diff --git a/tests/Fixtures/MailTracking.php b/tests/Fixtures/MailTracking.php index 80bc85c9f..1fd570e7e 100644 --- a/tests/Fixtures/MailTracking.php +++ b/tests/Fixtures/MailTracking.php @@ -8,6 +8,7 @@ /** * Trait MailTracking. + * * @method bool assertEmpty */ trait MailTracking @@ -55,7 +56,7 @@ protected function assertEmailWasNotSent() /** * Assert that the given number of emails were sent. * - * @param int $count + * @param int $count * @return MailTracking */ protected function assertEmailsSent($count) @@ -74,8 +75,8 @@ protected function assertEmailsSent($count) /** * Assert that the last email's body equals the given text. * - * @param string $body - * @param Swift_Message $message + * @param string $body + * @param Swift_Message $message * @return MailTracking */ protected function assertEmailEquals($body, Swift_Message $message = null) @@ -92,8 +93,8 @@ protected function assertEmailEquals($body, Swift_Message $message = null) /** * Assert that the last email's body contains the given text. * - * @param string $excerpt - * @param Swift_Message $message + * @param string $excerpt + * @param Swift_Message $message * @return MailTracking */ protected function assertEmailContains($excerpt, Swift_Message $message = null) @@ -110,8 +111,8 @@ protected function assertEmailContains($excerpt, Swift_Message $message = null) /** * Assert that the last email's subject matches the given string. * - * @param string $subject - * @param Swift_Message $message + * @param string $subject + * @param Swift_Message $message * @return MailTracking */ protected function assertEmailSubject($subject, Swift_Message $message = null) @@ -128,8 +129,8 @@ protected function assertEmailSubject($subject, Swift_Message $message = null) /** * Assert that the last email was sent to the given recipient. * - * @param string $recipient - * @param Swift_Message $message + * @param string $recipient + * @param Swift_Message $message * @return MailTracking */ protected function assertEmailTo($recipient, Swift_Message $message = null) @@ -146,8 +147,8 @@ protected function assertEmailTo($recipient, Swift_Message $message = null) /** * Assert that the last email was delivered by the given address. * - * @param string $sender - * @param Swift_Message $message + * @param string $sender + * @param Swift_Message $message * @return MailTracking */ protected function assertEmailFrom($sender, Swift_Message $message = null) @@ -164,7 +165,7 @@ protected function assertEmailFrom($sender, Swift_Message $message = null) /** * Store a new swift message. * - * @param Swift_Message $email + * @param Swift_Message $email */ public function addEmail(Swift_Message $email) { @@ -174,10 +175,10 @@ public function addEmail(Swift_Message $email) /** * Retrieve the appropriate swift message. * - * @param Swift_Message $message + * @param Swift_Message $message * @return mixed */ - protected function getEmail(\Swift_Message $message = null) + protected function getEmail(Swift_Message $message = null) { $this->assertEmailWasSent(); @@ -206,7 +207,7 @@ public function __construct($test) } /** - * @param \Swift_Events_SendEvent $event + * @param \Swift_Events_SendEvent $event */ public function beforeSendPerformed($event) { diff --git a/tests/Fixtures/Post/Post.php b/tests/Fixtures/Post/Post.php index 4e6d69b1d..5d73c0e18 100644 --- a/tests/Fixtures/Post/Post.php +++ b/tests/Fixtures/Post/Post.php @@ -10,6 +10,7 @@ /** * Class Post. + * * @property mixed $id * @property mixed $user_id * @property mixed $image diff --git a/tests/Fixtures/Post/PostPolicy.php b/tests/Fixtures/Post/PostPolicy.php index 61fa7951f..e6a91a8d4 100644 --- a/tests/Fixtures/Post/PostPolicy.php +++ b/tests/Fixtures/Post/PostPolicy.php @@ -39,7 +39,7 @@ public function deleteBulk($user, $post) return $_SERVER['restify.post.deleteBulk'] ?? true; } - public function delete($user = null, $post) + public function delete($user, $post) { return $_SERVER['restify.post.delete'] ?? true; } diff --git a/tests/Fixtures/User/SampleUser.php b/tests/Fixtures/User/SampleUser.php index b7c243cec..c6e361a75 100644 --- a/tests/Fixtures/User/SampleUser.php +++ b/tests/Fixtures/User/SampleUser.php @@ -17,7 +17,8 @@ public function getEmail() public function createToken($name, array $scopes = []) { - return new class () { + return new class() + { public $accessToken = 'token'; }; } diff --git a/tests/Fixtures/User/User.php b/tests/Fixtures/User/User.php index 07766b076..2f075007e 100644 --- a/tests/Fixtures/User/User.php +++ b/tests/Fixtures/User/User.php @@ -63,7 +63,8 @@ public function getEmail(): string public function createToken($name, array $scopes = []): object { - return new class () { + return new class() + { public $accessToken = 'token'; }; } diff --git a/tests/Fixtures/User/UserController.php b/tests/Fixtures/User/UserController.php index 7f71b741b..ff4151e54 100644 --- a/tests/Fixtures/User/UserController.php +++ b/tests/Fixtures/User/UserController.php @@ -26,7 +26,7 @@ public function index() /** * Store a newly created resource in storage. * - * @param Request $request + * @param Request $request * @return JsonResponse */ public function store(Request $request) @@ -37,8 +37,9 @@ public function store(Request $request) /** * Display the specified resource. * - * @param int $id + * @param int $id * @return JsonResponse + * * @throws EntityNotFoundException * @throws GatePolicy * @throws BindingResolutionException @@ -55,8 +56,8 @@ public function show($id) /** * Update the specified resource in storage. * - * @param Request $request - * @param int $id + * @param Request $request + * @param int $id * @return JsonResponse */ public function update(Request $request, $id) @@ -81,8 +82,9 @@ public function update(Request $request, $id) /** * Remove the specified resource from storage. * - * @param int $id + * @param int $id * @return JsonResponse + * * @throws BindingResolutionException * @throws EntityNotFoundException * @throws GatePolicy diff --git a/tests/Fixtures/User/UserPolicy.php b/tests/Fixtures/User/UserPolicy.php index 5188102c0..319bdfd79 100644 --- a/tests/Fixtures/User/UserPolicy.php +++ b/tests/Fixtures/User/UserPolicy.php @@ -7,7 +7,7 @@ class UserPolicy /** * Determine if the given user can use repository. * - * @param User|null $user + * @param User|null $user * @return bool|mixed */ public function allowRestify($user = null) diff --git a/tests/Prototypes/Prototypeable.php b/tests/Prototypes/Prototypeable.php index 51050446a..b823a44ec 100644 --- a/tests/Prototypes/Prototypeable.php +++ b/tests/Prototypes/Prototypeable.php @@ -54,7 +54,6 @@ public static function modelClass(): string|Model ->singular() ->__toString(); - if (class_exists($guessedClass = "Binaryk\\LaravelRestify\\Tests\Fixtures\\{$model}\\{$model}")) { return $guessedClass; } diff --git a/tests/Unit/AdvancedFilterTest.php b/tests/Unit/AdvancedFilterTest.php index bd8ded923..d26f9e0e9 100644 --- a/tests/Unit/AdvancedFilterTest.php +++ b/tests/Unit/AdvancedFilterTest.php @@ -14,7 +14,8 @@ class AdvancedFilterTest extends IntegrationTest { public function test_advanced_filters_can_serialize(): void { - $filter = new class () extends AdvancedFilter { + $filter = new class() extends AdvancedFilter + { public static $uriKey = 'status-filter'; public string $type = 'multiselect'; diff --git a/tests/Unit/MatchableFilterTest.php b/tests/Unit/MatchableFilterTest.php index ac924d107..09154ac9a 100644 --- a/tests/Unit/MatchableFilterTest.php +++ b/tests/Unit/MatchableFilterTest.php @@ -10,7 +10,8 @@ class MatchableFilterTest extends IntegrationTest { public function test_matchable_filter_has_key(): void { - $filter = new class () extends MatchFilter { + $filter = new class() extends MatchFilter + { public ?string $column = 'approved_at'; }; diff --git a/tests/Unit/RepositoryWithRoutesTest.php b/tests/Unit/RepositoryWithRoutesTest.php index e9d7cec51..bcf096010 100644 --- a/tests/Unit/RepositoryWithRoutesTest.php +++ b/tests/Unit/RepositoryWithRoutesTest.php @@ -30,7 +30,6 @@ public function test_can_add_custom_routes(): void 'success' => true, ]); - $this->getJson(route('main.testing.route')) ->assertOk() ->assertJson([ From 3e3e5fff5a0ad579b6c5c1fce8e1518dae4817d2 Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Fri, 22 Jul 2022 14:43:20 +0300 Subject: [PATCH 36/42] Docs 7x (#483) * fix: repository generator improvements & sanctum middleware scaffolding * Fix styling Co-authored-by: binaryk --- CHANGELOG.md | 2 +- ROADMAP.md | 2 +- UPGRADING.md | 4 +- config/restify.php | 2 +- docs-v2/content/en/auth/profile.md | 2 +- src/Commands/PublishAuthCommand.php | 27 +++++-------- src/Commands/RepositoryCommand.php | 6 ++- src/Commands/SetupCommand.php | 6 +++ src/Commands/stubs/Routes/routes.stub | 13 +++---- src/Commands/stubs/policy.stub | 12 +++--- src/Commands/stubs/repository.stub | 5 ++- src/Commands/stubs/user-policy.stub | 51 +++++++++++++++++++++++++ src/Commands/stubs/user-repository.stub | 10 ++--- src/Restify.php | 4 ++ src/Traits/InteractWithSearch.php | 2 +- 15 files changed, 99 insertions(+), 49 deletions(-) create mode 100644 src/Commands/stubs/user-policy.stub diff --git a/CHANGELOG.md b/CHANGELOG.md index 161ab72d3..49f0338e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to `laravel-restify` will be documented in this file - Nested relationship with custom columns (ie: `api/restify/company/include=users.posts[id, name].comments[title]`) - Restify will do not expose data without a defined policy for the resource - Performance improvements -- +- Hidden meta by default ## [5.0.0] 2021-05-23 diff --git a/ROADMAP.md b/ROADMAP.md index 0cfcfd9c3..ef125a904 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ - [x] Revisit the `InteractWithRepositories` trait and clean model queries accordingly - [x] Clean up all tests using AssertableJson [x] - [x] Make sure the `include` matches array key firstly, and secondly the relationship name -- [ ] Improve performance for queries and relationships +- [x] Improve performance for queries and relationships ### Features diff --git a/UPGRADING.md b/UPGRADING.md index eb7cb4595..adc668712 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -5,11 +5,11 @@ High impact: - Any action permitted unless the Model Policy exists and the method is defined -- PHP8.1 is required +- PHP8.0 is required - Laravel 9 is required - Repository.php: - static `to` method renamed to `route` - - `related` static method deleted, replace with `include` + - `$withs` class property was renamed to `$with` so it matches the Eloquent default - Relations that are present into `include` or `related` will be preloaded, so if you didn't specify a repository to serialize the related relationship, and you're looking for the Eloquent to resolve it, it will do not invoke the `restify.casts.related` cast anymore, instead it'll load the relationship as it. This has a performance reason under the hood. - Since related relationships will be preloaded, the format of the belongs to will be changed now. If you didn't specify the repository to serialize the `belongsTo` relationship, it'll be serialized as an object, not array anymore: diff --git a/config/restify.php b/config/restify.php index 855e9290e..43626f93c 100644 --- a/config/restify.php +++ b/config/restify.php @@ -95,7 +95,7 @@ 'middleware' => [ 'api', - // 'auth.sanctum', + //'auth:sanctum', DispatchRestifyStartingEvent::class, AuthorizeRestify::class, ], diff --git a/docs-v2/content/en/auth/profile.md b/docs-v2/content/en/auth/profile.md index 25c25d769..603aa4e62 100644 --- a/docs-v2/content/en/auth/profile.md +++ b/docs-v2/content/en/auth/profile.md @@ -7,7 +7,7 @@ position: 1 ## Sanctum middleware -To ensure you can get your profile, you should add the `Authenticate` middleware to the restify config: +To ensure you can get your profile, you should add the `auth:sanctum` middleware to the restify middleware config: ```php // config/restify.php diff --git a/src/Commands/PublishAuthCommand.php b/src/Commands/PublishAuthCommand.php index 9f34e23ab..10913c6a8 100644 --- a/src/Commands/PublishAuthCommand.php +++ b/src/Commands/PublishAuthCommand.php @@ -128,27 +128,18 @@ protected function setNamespace(string $stubDirectory, string $fileName, string */ protected function registerRoutes(): self { - $pathProvider = '../routes/api.php'; + $apiPath = base_path('routes/api.php'); + $initial = file_get_contents($apiPath); + + $initial = str($initial)->replace('Route::restifyAuth();', '')->toString(); + + $file = fopen($apiPath, 'w'); + $routeStub = __DIR__.'/stubs/Routes/routes.stub'; - file_put_contents(app_path($pathProvider), str_replace( - "use Illuminate\Support\Facades\Route;".PHP_EOL, - "use App\Http\Controllers\Restify\Auth\RegisterController;".PHP_EOL. - "use App\Http\Controllers\Restify\Auth\ForgotPasswordController;".PHP_EOL. - "use App\Http\Controllers\Restify\Auth\LoginController;".PHP_EOL. - "use App\Http\Controllers\Restify\Auth\ResetPasswordController;".PHP_EOL. - "use Illuminate\Support\Facades\Route;".PHP_EOL. - "use App\Http\Controllers\Restify\Auth\VerifyController;".PHP_EOL, - file_get_contents(app_path($pathProvider)) - )); + fwrite($file, $initial."\n".file_get_contents($routeStub)); - file_put_contents(app_path($pathProvider), str_replace( - 'Route::middleware(\'auth:api\')->get(\'/user\', function (Request $request) { - return $request->user(); -});', - file_get_contents($routeStub), - file_get_contents(app_path($pathProvider)) - )); + fclose($file); return $this; } diff --git a/src/Commands/RepositoryCommand.php b/src/Commands/RepositoryCommand.php index 4d757ecd0..63f69ada6 100644 --- a/src/Commands/RepositoryCommand.php +++ b/src/Commands/RepositoryCommand.php @@ -66,12 +66,14 @@ protected function buildClass($name) $name .= 'Repository'; } - return $this->replaceModel(parent::buildClass($name), $this->guessQualifiedModelName()); + return $this->replaceModel(parent::buildClass($name), $this->guessBaseModelClass()); } protected function replaceModel($stub, $class) { - return str_replace(['DummyClass', '{{ model }}', '{{model}}'], $class, $stub); + $model = str_replace(['DummyClass', '{{ modelBase }}', '{{modelBase}}'], "$class::class", $stub); + + return str_replace(['DummyClass', '{{ model }}', '{{model}}'], str($this->guessQualifiedModelName())->replace('\\\\', '\\').';', $model); } protected function guessBaseModelClass() diff --git a/src/Commands/SetupCommand.php b/src/Commands/SetupCommand.php index 780dad64d..17dc318b0 100644 --- a/src/Commands/SetupCommand.php +++ b/src/Commands/SetupCommand.php @@ -3,6 +3,7 @@ namespace Binaryk\LaravelRestify\Commands; use Illuminate\Console\Command; +use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Str; class SetupCommand extends Command @@ -32,6 +33,11 @@ public function handle() $this->callSilent('restify:repository', ['name' => 'User']); copy(__DIR__.'/stubs/user-repository.stub', app_path('Restify/UserRepository.php')); + if (! file_exists(app_path('Policies/UserPolicy.php'))) { + app(Filesystem::class)->ensureDirectoryExists(app_path('Policies')); + copy(__DIR__.'/stubs/user-policy.stub', app_path('Policies/UserPolicy.php')); + } + $this->setAppNamespace(); $this->info('Restify setup successfully.'); diff --git a/src/Commands/stubs/Routes/routes.stub b/src/Commands/stubs/Routes/routes.stub index 042e37b99..68b0da3ff 100644 --- a/src/Commands/stubs/Routes/routes.stub +++ b/src/Commands/stubs/Routes/routes.stub @@ -1,22 +1,19 @@ -Route::middleware('auth:api')->get('/user', function (Request $request) { - return $request->user(); -}); -Route::post('register', RegisterController::class) +Route::post('register', \App\Http\Controllers\Restify\Auth\RegisterController::class) ->name('restify.register'); -Route::post('login', LoginController::class) +Route::post('login', \App\Http\Controllers\Restify\Auth\LoginController::class) ->middleware('throttle:6,1') ->name('restify.login'); -Route::post('verify/{id}/{hash}', VerifyController::class) +Route::post('verify/{id}/{hash}', \App\Http\Controllers\Restify\Auth\VerifyController::class) ->middleware('throttle:6,1') ->name('restify.verify'); -Route::post('forgotPassword', ForgotPasswordController::class) +Route::post('forgotPassword', \App\Http\Controllers\Restify\Auth\ForgotPasswordController::class) ->middleware('throttle:6,1') ->name('restify.forgotPassword'); -Route::post('resetPassword', ResetPasswordController::class) +Route::post('resetPassword', \App\Http\Controllers\Restify\Auth\ResetPasswordController::class) ->middleware('throttle:6,1') ->name('restify.resetPassword'); diff --git a/src/Commands/stubs/policy.stub b/src/Commands/stubs/policy.stub index 16a950f6f..5c95e2922 100644 --- a/src/Commands/stubs/policy.stub +++ b/src/Commands/stubs/policy.stub @@ -22,31 +22,31 @@ class {{ class }} public function store(User $user): bool { - // + return false; } public function storeBulk(User $user): bool { - // + return false; } public function update(User $user, {{ model }} $model): bool { - // + return false; } public function updateBulk(User $user, {{ model }} $model): bool { - // + return false; } public function deleteBulk(User $user, {{ model }} $model): bool { - // + return false; } public function delete(User $user, {{ model }} $model): bool { - // + return false; } } diff --git a/src/Commands/stubs/repository.stub b/src/Commands/stubs/repository.stub index e09935927..23de04413 100644 --- a/src/Commands/stubs/repository.stub +++ b/src/Commands/stubs/repository.stub @@ -2,16 +2,17 @@ namespace DummyNamespace; -use Binaryk\LaravelRestify\Fields\Field; +use {{model}} use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; class DummyClass extends Repository { - public static $model = '{{ model }}'; + public static string $model = {{ modelBase }}; public function fields(RestifyRequest $request): array { return [ + id(), ]; } } diff --git a/src/Commands/stubs/user-policy.stub b/src/Commands/stubs/user-policy.stub new file mode 100644 index 000000000..a1db4e76b --- /dev/null +++ b/src/Commands/stubs/user-policy.stub @@ -0,0 +1,51 @@ +rules('required'), + field('name')->rules('required'), - Field::make('email')->storingRules('required', 'unique:users')->messages([ + field('email')->storingRules('required', 'unique:users')->messages([ 'required' => 'This field is required.', ]), ]; diff --git a/src/Restify.php b/src/Restify.php index 504076afc..d3535390a 100644 --- a/src/Restify.php +++ b/src/Restify.php @@ -159,6 +159,10 @@ public static function repositoriesFrom(string $directory): void $repositories = []; + if (! is_dir($directory)) { + return; + } + foreach ((new Finder())->in($directory)->files() as $repository) { $repository = $namespace.str_replace( ['/', '.php'], diff --git a/src/Traits/InteractWithSearch.php b/src/Traits/InteractWithSearch.php index b0931a472..f68fb4857 100644 --- a/src/Traits/InteractWithSearch.php +++ b/src/Traits/InteractWithSearch.php @@ -31,7 +31,7 @@ public static function searchables(): array public static function withs(): array { - return static::$withs ?? []; + return static::$with ?? []; } public static function related(): array From 51a839440dfe53f57db80e36fd2956a2a7c168ea Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Sat, 23 Jul 2022 18:41:23 +0300 Subject: [PATCH 37/42] Docs 7x (#484) * fix: repository generator improvements & sanctum middleware scaffolding * feat: [7.x] Eager fields recognize pattern of the key to retrieve repository. * Fix styling * fix: updating docs for v7 * fix: merge * Fix styling * fix: wip * fix: wip * Fix styling Co-authored-by: binaryk --- CHANGELOG.md | 16 ++ UPGRADING.md | 3 + docs-v2/content/en/api/relations.md | 186 ++++++++++++++---- docs-v2/content/en/api/repositories.md | 157 +++++++++++++-- src/Bootstrap/RoutesBoot.php | 20 ++ src/Bootstrap/RoutesDefinition.php | 21 +- src/Fields/BelongsTo.php | 17 +- src/Fields/BelongsToMany.php | 11 +- src/Fields/EagerField.php | 29 ++- src/Fields/HasMany.php | 11 +- src/Fields/HasOne.php | 13 -- .../Concerns/InteractWithRepositories.php | 2 +- src/Repositories/Repository.php | 12 ++ src/Restify.php | 23 ++- src/RestifyApplicationServiceProvider.php | 2 +- .../Search/RepositorySearchService.php | 19 +- src/Traits/InteractWithSearch.php | 4 +- .../Index/RepositoryIndexControllerTest.php | 16 +- tests/Feature/RepositorySearchServiceTest.php | 4 +- tests/Fields/EagerFieldTest.php | 24 +++ tests/IntegrationTest.php | 7 +- tests/Repositories/PublicRepositoriesTest.php | 36 ++++ 22 files changed, 500 insertions(+), 133 deletions(-) create mode 100644 tests/Fields/EagerFieldTest.php create mode 100644 tests/Repositories/PublicRepositoriesTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f0338e5..fa0114bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ All notable changes to `laravel-restify` will be documented in this file - Restify will do not expose data without a defined policy for the resource - Performance improvements - Hidden meta by default +- You don't have to specify the key and the repository that serializes related entities: +```php +// before +// related(): array +return [ + 'posts' => HasMany::make('posts', PostRepository::class), +]; +``` + +```php +// after +// related(): array +return [ + HasMany::make('posts'), +]; +``` ## [5.0.0] 2021-05-23 diff --git a/UPGRADING.md b/UPGRADING.md index adc668712..4238002d7 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -10,6 +10,9 @@ High impact: - Repository.php: - static `to` method renamed to `route` - `$withs` class property was renamed to `$with` so it matches the Eloquent default + - `$defaultPerPage` and `$defaultRelatablePerPage` has a type of `int`, if you override this make sure you add `int` type + - `eagerState` method was deleted from the repository, there is no need to call it anymore, the repository will be resolved automatically + - `$prefix` property requires a `string` type - Relations that are present into `include` or `related` will be preloaded, so if you didn't specify a repository to serialize the related relationship, and you're looking for the Eloquent to resolve it, it will do not invoke the `restify.casts.related` cast anymore, instead it'll load the relationship as it. This has a performance reason under the hood. - Since related relationships will be preloaded, the format of the belongs to will be changed now. If you didn't specify the repository to serialize the `belongsTo` relationship, it'll be serialized as an object, not array anymore: diff --git a/docs-v2/content/en/api/relations.md b/docs-v2/content/en/api/relations.md index 113532d80..0a78cf766 100644 --- a/docs-v2/content/en/api/relations.md +++ b/docs-v2/content/en/api/relations.md @@ -33,6 +33,101 @@ Say we have a User that has a list of posts, we will define it this way: ```php HasMany::make('posts', PostRepository::class), ``` +or: +```php +HasMany::make('posts'), +``` + + +Restify 7+ will guess the serialization repository using the key, so you don't necessarily have to specify it: + + + +### Related Declaration + +Let's see how can we inform a repository about its relationships: + +```php +// CompanyRepository +public static function related(): array +{ + return [ + 'usersRelationship' => HasMany::make('users', UserRepository::class), + + HasMany::make('posts'), + + 'extraData' => fn() => ['location' => 'Romania'], + + 'extraMeta' => new Invokable() + + 'country', + ]; +} +``` + +Above we can see few types of relationship declaration Restify provides. Let's explain them. + +#### Long definition + +```php +'usersRelationship' => HasMany::make('users', UserRepository::class), +``` + +This means that there is a relationship of type `hasMany` declared in the Company model. The Eloquent relationship name is `users` (see the first argument of the HasMany field): + +```php +// app/Models/Company.php +public function users(): \Illuminate\Database\Eloquent\Relations\HasMany +{ + return $this->hasMany(User::class); +} +``` + +The key `usersRelationship` represents the query param the API exposes to load the list of users: + +```http request +GET: api/companies?related=usersRelationship +``` + +The `UserRepository` represents the repository class that serializes the users list. + +#### Short definition + +```php +HasMany::make('posts'), +``` + +Usually the key (query param) and the actual Eloquent relationship names are the same, so Restify provides a shorter version of defining the relationship. + +In this case the name of the query param will be the same as the relationship name - `posts`. The name of the repository `PostRepository` will be resolved based on the same key and $uriKey of the repository. + +The request will look like this: + +```http request +GET: api/companies?related=posts +``` + +#### Callables + +```php +'extraData' => fn() => ['location' => 'Romania'], + +'extraMeta' => new Invokable() +``` +Restify allow you to resolve specific data using callable functions or invokable (classes with a single public __invoke method). You can return any kind of data from these callables, it'll be serialized accordingly. The query param in this case should match the key: + +```http request +GET: api/companies?related=extraData,extraMeta +``` + +#### Forwarding + +```php +'country', +``` + +If you simply define a key in the `related`, Restify will forward your request to the associated model. Your model could return anything, it might be an Eloquent relationship or any primary data. + Let's take a look over all relationships Restify provides: @@ -57,10 +152,11 @@ GET /users/1?include=posts[title|description] Let's assume you have the `CompanyRepository`: ```php +// CompanyRepository public static function related(): array { return [ - 'users' => HasMany::make('users', UserRepository::class), + HasMany::make('users), ]; } ``` @@ -68,11 +164,24 @@ public static function related(): array In the UserRepository you have a relationship to a list of user posts and roles: ```php +// UserRepository public static function related(): array { return [ - 'posts' => HasMany::make('posts', PostRepository::class), - 'roles' => MorphToMany::make('roles', RoleRepository::class), + HasMany::make('posts'), + MorphToMany::make('roles'), + ]; +} +``` + +And in `PostRepository` you might have a list of comments for each post: + +```php +// PostRepository +public static function related(): array +{ + return [ + HasMany::make('comments'), ]; } ``` @@ -100,24 +209,12 @@ This request will return a list like this: "attributes": { "name": "Eduard" }, - "meta": { - "authorizedToShow": true, - "authorizedToStore": true, - "authorizedToUpdate": false, - "authorizedToDelete": false - }, "relationships": { "posts": [{ "id": "1", "type": "posts", "attributes": { "title": "Post title" - }, - "meta": { - "authorizedToShow": true, - "authorizedToStore": true, - "authorizedToUpdate": false, - "authorizedToDelete": false } }], "roles": [{ @@ -125,27 +222,35 @@ This request will return a list like this: "type": "roles", "attributes": { "name": "admin" - }, - "meta": { - "authorizedToShow": true, - "authorizedToStore": true, - "authorizedToUpdate": false, - "authorizedToDelete": false } }] } }] - }, - "meta": { - "authorizedToShow": true, - "authorizedToStore": true, - "authorizedToUpdate": true, - "authorizedToDelete": true } } } ``` +You can also specify load the `comments` of the `posts`: + +```http request +GET: /api/restify/companies?include=users.posts.comments,users.roles +``` + +Or specify exact columns you want to load for each nested layer: + +```http request +GET: /api/restify/companies?include=users[name].posts[id|title].comments[comment],users.roles[name] +``` + + +Getting specific columns will make your requests more performant. + + + +### Meta information + +Starting with Restify 7+ meta information for related (in index requests) will do not be displayed. For more details read the [repository meta](/api/repositories#index-item-meta). ## BelongsTo & MorphOne @@ -217,14 +322,19 @@ GET: api/restify/posts/1?include=owner ### Searchable belongs to -The `BelongsTo` field allows you to use the search endpoint to [search over a column](/search/basic-filters#repository-search) from the `belongsTo` relationship by simply using the `searchables`: +The `BelongsTo` field allows you to use the search endpoint to [search over a column](/search/basic-filters#repository-search) from the `belongsTo` relationship by simply using the `searchables` call: ```php -BelongsTo::make('user', UserRepository::class)->searchable(['name']) +BelongsTo::make('user')->searchable('name') ``` -The `searchable` method accepts an array of database columns from the related entity (`users` in our case). +The `searchable` method accepts a list of database attributes from the related entity (`users` in our case). +So if we get the following search request, it'll also search into the related user's name: + +```http request +GET: api/restify/companies?related=user&search="John" +``` ## HasOne @@ -237,7 +347,7 @@ For example, let's assume a `User` model `hasOne` `Phone` model. We may add the public static function related(): array { return [ - \Binaryk\LaravelRestify\Fields\HasOne::new('phone', PhoneRepository::class), + \Binaryk\LaravelRestify\Fields\HasOne::make('phone', PhoneRepository::class), ]; } ``` @@ -279,7 +389,7 @@ model `hasMany` `Post` models. We may add the relationship to our `UserRepositor public static function related(): array { return [ - \Binaryk\LaravelRestify\Fields\HasMany::new('posts', PostRepository::class), + \Binaryk\LaravelRestify\Fields\HasMany::make('posts', PostRepository::class), ]; } ``` @@ -328,7 +438,7 @@ So you will get back the `posts` relationship: repository being in this case the class of the related resource) class using: ```php -public static $defaultRelatablePerPage = 100; +public static int $defaultRelatablePerPage = 100; ``` @@ -336,6 +446,10 @@ public static $defaultRelatablePerPage = 100; You can also use the query `?relatablePerPage=100`. +```http request +GET: api/restify/users?related=posts&relatablePerPage=100 +``` + When using `relatablePerPage` query param, it will paginate all relatable entities with that size. @@ -352,7 +466,7 @@ model `belongsToMany` Role models. We may add the relationship to our UserReposi public static function related(): array { return [ - \Binaryk\LaravelRestify\Fields\BelongsToMany::new('users', UserRepository::class), + \Binaryk\LaravelRestify\Fields\BelongsToMany::make('users', UserRepository::class), ]; } ``` @@ -369,8 +483,8 @@ imagine we have a `policy` field that contains some simple text about the relati to the `BelongsToMany` field using the fields method: ```php -BelongsToMany::new('users', RoleRepository::class)->withPivot( - Field::new('is_admin') +BelongsToMany::make('users', RoleRepository::class)->withPivot( + field('is_admin') ), ``` diff --git a/docs-v2/content/en/api/repositories.md b/docs-v2/content/en/api/repositories.md index af3967e26..0c5433ec4 100644 --- a/docs-v2/content/en/api/repositories.md +++ b/docs-v2/content/en/api/repositories.md @@ -33,7 +33,7 @@ use App\Restify\Repository; class PostRepository extends Repository { - public static $model = Post::class; + public static string $model = Post::class; public function fields(RestifyRequest $request) { @@ -42,6 +42,11 @@ class PostRepository extends Repository } ``` + +If if you don't specify the $model property, Restify will try to guess the model automatically. + + + The `fields` method returns the default set of attributes definitions that should be applied during API requests. ### Model & Repository Discovery Conventions @@ -59,23 +64,27 @@ Having this in place you're basically ready for the CRUD actions over posts. You | Verb | URI | Action | |:-----------|:------------------------------------------------------|:---------------------------------------------| | **GET** | `/api/restify/posts` | index | -| **GET** | `/api/restify/posts/actions` | index actions | +| **GET** | `/api/restify/posts/actions` | display index actions | +| **GET** | `/api/restify/posts/getters` | display index getters | | **GET** | `/api/restify/posts/{post}` | show | -| **GET** | `/api/restify/posts/{post}/actions` | individual actions | +| **GET** | `/api/restify/posts/{post}/actions` | display individual actions | +| **GET** | `/api/restify/posts/{post}/getters` | display individual getters | | **POST** | `/api/restify/posts` | store | | **POST** | `/api/restify/posts/actions?action=actionName` | perform index actions | +| **GET** | `/api/restify/posts/getters?getter=getterName` | retrieve index getters | | **POST** | `/api/restify/posts/bulk` | store multiple | | **DELETE** | `/api/restify/posts/bulk/delete` | delete multiple | | **POST** | `/api/restify/posts/bulk/update` | update multiple | | **PATCH** | `/api/restify/posts/{post}` | partial update | | **PUT** | `/api/restify/posts/{post}` | full update | | **POST** | `/api/restify/posts/{post}` | partial of full update including attachments | -| **POST** | `/api/restify/posts/{post}/actions?action=actionName` | perform index actions | +| **POST** | `/api/restify/posts/{post}/actions?action=actionName` | perform individual actions | +| **GET** | `/api/restify/posts/{post}/getters?getter=getterName` | retrieve individual getter | | **DELETE** | `/api/restify/posts/{post}` | destroy | -Update with files As you can see we provide 3 Verbs for the model update (PUT, PATCH, POST), the reason of that is +As you can see we provide 3 Verbs for the model update (PUT, PATCH, POST), the reason of that is because you cannot send files via `PATCH` or `PUT` verbs, so we have `POST`. Where the `PUT` or `PATCH` could be used for full model update and respectively partial update. @@ -85,10 +94,114 @@ for full model update and respectively partial update. As we already noticed, each repository basically works as a wrapper over a specific resource. The fancy naming `resource` is nothing more than a database entity (posts, users etc.). Well, to make the repository aware of the -entity it should take care of, we have to define the model property: +entity it should take care of, we have to define the model property associated to that resource: + +```php +public static string $model = 'App\\Models\\Post'; +``` + +## Public repository + +Sometimes you need to expose a public information (so unauthenticated users could access it). + + + +We highly recommend avoid this kind of exposure. If you need to expose custom data, you can use the [serializer](/api/serializer) to return a json:api format from any custom route/controller (still using the power of repositories). + + + +Restify allows you to define a public repositories by adding the `$public` property on true: + + +```php +public static bool|array $public = true; +``` + +When adding the `$public` flag, the repository will expose ONLY GET requests publicly. These requests are: + + +| Verb | URI | Action | +|:-----------|:------------------------------------------------------|:---------------------------------------------| +| **GET** | `/api/restify/posts` | index | +| **GET** | `/api/restify/posts/getters` | display index getters | +| **GET** | `/api/restify/posts/{post}` | show | +| **GET** | `/api/restify/posts/{post}/getters` | display individual getters | +| **GET** | `/api/restify/posts/getters?getter=getterName` | retrieve index getters | +| **GET** | `/api/restify/posts/{post}/getters?getter=getterName` | retrieve individual getter | + +In order to get the public functionality you need to take few extra steps to inform your setup it might have public access. + +### Public gate + +Make sure you allow your global gate a nullable user: ```php -public static $model = 'App\\Models\\Post'; +// app/Providers/RestifyApplicationServiceProvider.php + +protected function gate(): void +{ + Gate::define('viewRestify', function ($user = null) { + if (is_null($user)) { + return true; + } + + return in_array($user->email, [...]) + }); +} +``` + +### Public Policies + +As we know, each model should be protected by a policy. The policy that corresponds to a public repository should also allow nullable authenticated user: + +```php +// ie: PostPolicy +public function allowRestify(User $user = null): bool +{ + return true; +} + +public function show(User $user = null, User $model): bool +{ + return true; +} +``` + +Having these configurations in place, you should be good to expose the repository publicly. + +## Repository key + +The repository URI segment is automatically generated using the repository name. The php method that does that is: + +```php +public static function uriKey(): string +{ + if (property_exists(static::class, 'uriKey') && is_string(static::$uriKey)) { + return static::$uriKey; + } + + $kebabWithoutRepository = Str::kebab(Str::replaceLast('Repository', '', class_basename(get_called_class()))); + + /** + * e.g. UserRepository => users + * e.g. LaravelEntityRepository => laravel-entities. + */ + return Str::plural($kebabWithoutRepository); +} +``` + +As you can see, you can override this, or define your own `public static string $uriKey` to the repository, so you get a custom repository uri segment. For example, if we want to call our users as `members` we will do: + +```php +// UserRepository + +public static string $uriKey = 'members'; +``` + +So the request is: + +```http request +GET: api/restify/members ``` ## Fields @@ -112,9 +225,9 @@ class PostRepository extends Repository public function fields(RestifyRequest $request) { return [ - Field::make('title'), + field('title'), - Field::make('description'), + field('description'), ]; } } @@ -270,6 +383,12 @@ This is a standard index `api/restify/posts` response: } ``` + + +From Restify 7+ the meta on index requests will do not be loaded any more because of performance reasons. See [index item meta](/api/repositories#index-item-meta) for more details. + + + ### Index main meta Firstly we have the `meta` object, by default this includes pagination information, so your frontend could be adapted @@ -331,6 +450,16 @@ so, if not, it will be filtered out from this response. ### Index item meta +In order to optimize requests Restify 7+ will do not provide any meta information about the repositories (including nested relationships) for index requests (ie / `posts`). You can enable them by editing the config `restify.repositories.serialize_index_meta`. + +Or you can enable them specifically per request by adding the query param `withMeta=true`: + +```http request +GET: /api/restify/posts?withMeta=true +``` + +This also applies for any related information. + The individual item object format is pretty much the same as we have for the [show](#show-request). However, you can specify a custom metadata for these items by using: @@ -362,7 +491,7 @@ By default, attributes used to serialize the index item, are the same from the ` public function fieldsForIndex(RestifyRequest $request): array { return [ - Field::make('title'), + field('title'), ]; } ``` @@ -382,9 +511,9 @@ Store, is a `post` request, usually used to create/store entities. Let's take a public function fields(RestifyRequest $request) { return [ - Field::make('title'), + field('title'), - Field::make('description'), + field('description'), ]; } ``` @@ -448,7 +577,7 @@ $request->validate([ To do this in Restify, you have to apply the Field's `storingRules`: ```php -Field::make('description')->storingRules('required'), +field('description')->storingRules('required'), ``` So the rules list will be applied for the underlining field. @@ -521,7 +650,7 @@ The Restify response contains the http 200 status, and the following response: To validate certain fields we can use the Field's `updatingRules` method: ```php -Field::make('description')->updatingRules('required'), +field('description')->updatingRules('required'), ``` ### Custom update diff --git a/src/Bootstrap/RoutesBoot.php b/src/Bootstrap/RoutesBoot.php index f6144cda1..002f697d6 100644 --- a/src/Bootstrap/RoutesBoot.php +++ b/src/Bootstrap/RoutesBoot.php @@ -21,6 +21,7 @@ public function boot(): void $this ->registerPrefixed($config) + ->registerPublic($config) ->defaultRoutes($config); } @@ -51,4 +52,23 @@ public function registerPrefixed($config): self return $this; } + + public function registerPublic($config): self + { + collect(Restify::$repositories) + ->each(function (string $repository) use ($config) { + /** + * @var Repository $repository + */ + if (! $repository::isPublic()) { + return; + } + + Route::group($config, function () use ($repository) { + app(RoutesDefinition::class)->withoutMiddleware('auth:sanctum')($repository::uriKey()); + }); + }); + + return $this; + } } diff --git a/src/Bootstrap/RoutesDefinition.php b/src/Bootstrap/RoutesDefinition.php index a3f6ea56a..e0908d12a 100644 --- a/src/Bootstrap/RoutesDefinition.php +++ b/src/Bootstrap/RoutesDefinition.php @@ -11,6 +11,8 @@ class RoutesDefinition { + private array $excludedMiddleware = []; + public function __invoke(string $uriKey = null) { $prefix = $uriKey ?: '{repository}'; @@ -51,25 +53,25 @@ public function __invoke(string $uriKey = null) Route::get( $prefix.'/getters', \Binaryk\LaravelRestify\Http\Controllers\ListGettersController::class - )->name('restify.getters.index'); + )->name('restify.getters.index')->withoutMiddleware($this->excludedMiddleware); Route::get( $prefix.'/{repositoryId}/getters', \Binaryk\LaravelRestify\Http\Controllers\ListRepositoryGettersController::class - )->name('restify.getters.repository.index'); + )->name('restify.getters.repository.index')->withoutMiddleware($this->excludedMiddleware); Route::get( $prefix.'/getters/{getter}', \Binaryk\LaravelRestify\Http\Controllers\PerformGetterController::class - )->name('restify.getters.perform'); + )->name('restify.getters.perform')->withoutMiddleware($this->excludedMiddleware); Route::get( $prefix.'/{repositoryId}/getters/{getter}', \Binaryk\LaravelRestify\Http\Controllers\PerformRepositoryGetterController::class - )->name('restify.getters.repository.perform'); + )->name('restify.getters.repository.perform')->withoutMiddleware($this->excludedMiddleware); // API CRUD Route::get( $prefix.'', \Binaryk\LaravelRestify\Http\Controllers\RepositoryIndexController::class - )->name('index'); + )->name('index')->withoutMiddleware('auth:sanctum'); Route::post( $prefix.'', \Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreController::class @@ -89,7 +91,7 @@ public function __invoke(string $uriKey = null) Route::get( $prefix.'/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryShowController::class - )->name('restify.show'); + )->name('restify.show')->withoutMiddleware($this->excludedMiddleware); Route::patch( $prefix.'/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryPatchController::class @@ -167,4 +169,11 @@ public function once(): void RestifySanctumAuthenticate::class, ); } + + public function withoutMiddleware(...$middleware): self + { + $this->excludedMiddleware = $middleware; + + return $this; + } } diff --git a/src/Fields/BelongsTo.php b/src/Fields/BelongsTo.php index 3febcc356..066a1cbda 100644 --- a/src/Fields/BelongsTo.php +++ b/src/Fields/BelongsTo.php @@ -6,7 +6,6 @@ use Binaryk\LaravelRestify\Fields\Concerns\CanSort; use Binaryk\LaravelRestify\Fields\Contracts\Sortable; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; -use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; @@ -17,18 +16,6 @@ class BelongsTo extends EagerField implements Sortable public ?array $searchablesAttributes = null; - public function __construct($relation, $parentRepository) - { - if (! is_a(app($parentRepository), Repository::class)) { - abort(500, "Invalid parent repository [{$parentRepository}]. Expended instance of ".Repository::class); - } - - parent::__construct(attribute: $relation); - - $this->relation = $relation; - $this->repositoryClass = $parentRepository; - } - public function fillAttribute(RestifyRequest $request, $model, int $bulkRow = null) { /** * @var Model $relatedModel */ @@ -57,9 +44,9 @@ public function fillAttribute(RestifyRequest $request, $model, int $bulkRow = nu ); } - public function searchable(array $attributes): self + public function searchable(...$attributes): self { - $this->searchablesAttributes = $attributes; + $this->searchablesAttributes = collect($attributes)->flatten()->all(); return $this; } diff --git a/src/Fields/BelongsToMany.php b/src/Fields/BelongsToMany.php index c8defa5fa..9ebaf602f 100644 --- a/src/Fields/BelongsToMany.php +++ b/src/Fields/BelongsToMany.php @@ -28,16 +28,9 @@ class BelongsToMany extends EagerField */ public $detachCallback; - public function __construct($relation, $parentRepository) + public function __construct($relation, string $parentRepository = null) { - if (! is_a(app($parentRepository), Repository::class)) { - abort(500, "Invalid parent repository [{$parentRepository}]. Expended instance of ".Repository::class); - } - - parent::__construct(attribute: $relation); - - $this->relation = $relation; - $this->repositoryClass = $parentRepository; + parent::__construct($relation, $parentRepository); $this->readonly(); } diff --git a/src/Fields/EagerField.php b/src/Fields/EagerField.php index 81b8fa844..9c7fe0a7a 100644 --- a/src/Fields/EagerField.php +++ b/src/Fields/EagerField.php @@ -3,6 +3,7 @@ namespace Binaryk\LaravelRestify\Fields; use Binaryk\LaravelRestify\Repositories\Repository; +use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Traits\HasColumns; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\Model; @@ -24,10 +25,30 @@ class EagerField extends Field /** * The class name of the related repository. * - * @var Repository + * @var string */ public string $repositoryClass; + public function __construct($attribute, string $parentRepository = null) + { + parent::__construct(attribute: $attribute); + + $this->relation = $attribute; + + if (is_string($parentRepository)) { + $this->repositoryClass = $parentRepository; + } + + if (is_null($parentRepository)) { + $this->repositoryClass = tap(Restify::repositoryClassForKey($attribute), + fn ($repository) => abort_unless($repository, 400, "Repository not found for the key [$attribute].")); + } + + if (! isset($this->repositoryClass)) { + abort(400, "Invalid parent repository [{$parentRepository}]. Expended instance of ".Repository::class); + } + } + /** * Determine if the field should be displayed for the given request. * @@ -37,9 +58,9 @@ class EagerField extends Field public function authorize(Request $request) { return call_user_func( - [$this->repositoryClass, 'authorizedToUseRepository'], - $request - ) && parent::authorize($request); + [$this->repositoryClass, 'authorizedToUseRepository'], + $request + ) && parent::authorize($request); } public function resolve($repository, $attribute = null) diff --git a/src/Fields/HasMany.php b/src/Fields/HasMany.php index fcc0c6a78..234cba238 100644 --- a/src/Fields/HasMany.php +++ b/src/Fields/HasMany.php @@ -12,16 +12,9 @@ class HasMany extends EagerField { protected $canEnableRelationshipCallback; - public function __construct($relation, $parentRepository) + public function __construct($relation, string $parentRepository = null) { - if (! is_a(app($parentRepository), Repository::class)) { - abort(500, "Invalid parent repository [{$parentRepository}]. Expended instance of ".Repository::class); - } - - parent::__construct(attribute: $relation); - - $this->relation = $relation; - $this->repositoryClass = $parentRepository; + parent::__construct($relation, $parentRepository); $this->readonly(); } diff --git a/src/Fields/HasOne.php b/src/Fields/HasOne.php index 9c151e37c..7b46a52bd 100644 --- a/src/Fields/HasOne.php +++ b/src/Fields/HasOne.php @@ -5,24 +5,11 @@ use Binaryk\LaravelRestify\Fields\Concerns\CanSort; use Binaryk\LaravelRestify\Fields\Contracts\Sortable; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; -use Binaryk\LaravelRestify\Repositories\Repository; class HasOne extends EagerField implements Sortable { use CanSort; - public function __construct($relation, $parentRepository) - { - if (! is_a(app($parentRepository), Repository::class)) { - abort(500, "Invalid HasOne repository [{$parentRepository}]. Expended instance of ".Repository::class); - } - - parent::__construct(attribute: $relation); - - $this->relation = $relation; - $this->repositoryClass = $parentRepository; - } - public function fillAttribute(RestifyRequest $request, $model, int $bulkRow = null) { // diff --git a/src/Http/Requests/Concerns/InteractWithRepositories.php b/src/Http/Requests/Concerns/InteractWithRepositories.php index 1c30b9f3b..6d0a75027 100644 --- a/src/Http/Requests/Concerns/InteractWithRepositories.php +++ b/src/Http/Requests/Concerns/InteractWithRepositories.php @@ -60,7 +60,7 @@ public function repository($key = null): Repository return $repository; } catch (RepositoryException $e) { - abort($e->getCode(), $e->getMessage()); + abort($e->getCode() ?: 400, $e->getMessage()); } } diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 278da5769..7089342c3 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -155,6 +155,13 @@ class Repository implements RestifySearchable, JsonSerializable */ public static array $detachers = []; + /** + * Specify whether the repository could be accessed public. + * + * @var bool|array + */ + public static bool|array $public = false; + /** * Indicates if the repository is serializing for a eager relationship. * @@ -1143,4 +1150,9 @@ public static function serializer(): Serializer { return new Serializer(app(static::class)); } + + public static function isPublic(): bool + { + return static::$public; + } } diff --git a/src/Restify.php b/src/Restify.php index d3535390a..0b40beba6 100644 --- a/src/Restify.php +++ b/src/Restify.php @@ -66,11 +66,10 @@ public static function repositoryClassForPrefix(string $prefix): ?string { return collect(static::$repositories)->first(function ($value) use ($prefix) { /** * @var Repository $value */ - return - $value::route() - ->whenStartsWith('/', fn ($string) => $string->replaceFirst('/', ''))->is( - str($prefix)->whenStartsWith('/', fn ($string) => $string->replaceFirst('/', '')) - ); + return str($prefix)->whenStartsWith('/', fn ($string) => $string->replaceFirst('/', ''))->contains( + $value::route() + ->whenStartsWith('/', fn ($string) => $string->replaceFirst('/', '')), + ); }); } @@ -165,15 +164,15 @@ public static function repositoriesFrom(string $directory): void foreach ((new Finder())->in($directory)->files() as $repository) { $repository = $namespace.str_replace( - ['/', '.php'], - ['\\', ''], - Str::after($repository->getPathname(), app_path().DIRECTORY_SEPARATOR) - ); + ['/', '.php'], + ['\\', ''], + Str::after($repository->getPathname(), app_path().DIRECTORY_SEPARATOR) + ); if (is_subclass_of( - $repository, - Repository::class - ) && (new ReflectionClass($repository))->isInstantiable()) { + $repository, + Repository::class + ) && (new ReflectionClass($repository))->isInstantiable()) { $repositories[] = $repository; } } diff --git a/src/RestifyApplicationServiceProvider.php b/src/RestifyApplicationServiceProvider.php index f8bca8837..1be72889b 100644 --- a/src/RestifyApplicationServiceProvider.php +++ b/src/RestifyApplicationServiceProvider.php @@ -70,7 +70,7 @@ protected function authorization(): void */ protected function gate(): void { - Gate::define('viewRestify', function ($user) { + Gate::define('viewRestify', function ($user = null) { return in_array($user->email, [ // ], true); diff --git a/src/Services/Search/RepositorySearchService.php b/src/Services/Search/RepositorySearchService.php index ed1cfcc09..b9ff782b4 100644 --- a/src/Services/Search/RepositorySearchService.php +++ b/src/Services/Search/RepositorySearchService.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Collection; use Illuminate\Support\Stringable; +use Throwable; class RepositorySearchService { @@ -35,9 +36,9 @@ public function search(RestifyRequest $request, Repository $repository): Builder $repository::usesScout() ? $this->prepareRelations($request, $scoutQuery ?? $repository::query($request)) : $this->prepareSearchFields( - $request, - $this->prepareRelations($request, $scoutQuery ?? $repository::query($request)), - ), + $request, + $this->prepareRelations($request, $scoutQuery ?? $repository::query($request)), + ), ); $query = $this->applyFilters($request, $repository, $query); @@ -85,8 +86,8 @@ public function prepareRelations(RestifyRequest $request, Builder|Relation $quer ->forRequest($request, $this->repository) ->map( fn ($relation) => $relation instanceof EagerField - ? $relation->relation - : $relation + ? $relation->relation + : $relation ) ->values() ->unique() @@ -100,7 +101,13 @@ public function prepareRelations(RestifyRequest $request, Builder|Relation $quer str($relationships)->whenContains('.', fn (Stringable $string) => $string->before('.'))->toString(), $eager, true, - ))->all(); + ))->filter(function ($relation) use ($query) { + try { + return $query->getRelation($relation) instanceof Relation; + } catch (Throwable) { + return false; + } + })->all(); return $query->with( array_merge($filtered, ($this->repository)::withs()) diff --git a/src/Traits/InteractWithSearch.php b/src/Traits/InteractWithSearch.php index f68fb4857..52d4e1c84 100644 --- a/src/Traits/InteractWithSearch.php +++ b/src/Traits/InteractWithSearch.php @@ -18,9 +18,9 @@ trait InteractWithSearch { use AuthorizableModels; - public static $defaultPerPage = 15; + public static int $defaultPerPage = 15; - public static $defaultRelatablePerPage = 15; + public static int $defaultRelatablePerPage = 15; public static function searchables(): array { diff --git a/tests/Controllers/Index/RepositoryIndexControllerTest.php b/tests/Controllers/Index/RepositoryIndexControllerTest.php index bd9c6d49e..4317d20bf 100644 --- a/tests/Controllers/Index/RepositoryIndexControllerTest.php +++ b/tests/Controllers/Index/RepositoryIndexControllerTest.php @@ -185,6 +185,8 @@ public function test_can_retrieve_nested_relationships(): void ->shouldReceive('include') ->andReturn([ 'users' => HasMany::make('users', UserRepository::class), + 'extraData' => fn () => ['country' => 'Romania'], + 'extraMeta' => new InvokableExtraMeta, ]); UserRepository::partialMock() @@ -204,7 +206,7 @@ public function test_can_retrieve_nested_relationships(): void )->create(); $this->withoutExceptionHandling()->getJson(CompanyRepository::route(null, [ - 'related' => 'users.companies.users, users.posts, users.roles', + 'related' => 'users.companies.users, users.posts, users.roles, extraData, extraMeta', ]))->assertJson( fn (AssertableJson $json) => $json ->where('data.0.type', 'companies') @@ -215,6 +217,8 @@ public function test_can_retrieve_nested_relationships(): void ->where('data.0.relationships.users.0.relationships.posts.0.type', 'posts') ->where('data.0.relationships.users.0.relationships.roles.0.type', 'roles') ->where('data.0.relationships.users.0.relationships.companies.0.type', 'companies') + ->where('data.0.relationships.extraData', ['country' => 'Romania']) + ->where('data.0.relationships.extraMeta', ['userCount' => 10]) ->etc() ); } @@ -308,3 +312,13 @@ public function test_can_add_custom_index_main_meta_attributes(): void $this->assertEquals('Post Title', $response->json('meta.first_title')); } } + +class InvokableExtraMeta +{ + public function __invoke() + { + return [ + 'userCount' => 10, + ]; + } +} diff --git a/tests/Feature/RepositorySearchServiceTest.php b/tests/Feature/RepositorySearchServiceTest.php index f885354d1..b4bfe97ba 100644 --- a/tests/Feature/RepositorySearchServiceTest.php +++ b/tests/Feature/RepositorySearchServiceTest.php @@ -69,9 +69,7 @@ public function test_can_search_using_belongs_to_field(): void ]); PostRepository::$related = [ - 'user' => BelongsTo::make('user', UserRepository::class)->searchable([ - 'users.name', - ]), + BelongsTo::make('user', UserRepository::class)->searchable('name'), ]; $this->getJson(PostRepository::route(query: ['search' => 'John'])) diff --git a/tests/Fields/EagerFieldTest.php b/tests/Fields/EagerFieldTest.php new file mode 100644 index 000000000..cfe177f11 --- /dev/null +++ b/tests/Fields/EagerFieldTest.php @@ -0,0 +1,24 @@ +assertSame(UserRepository::class, $field->repositoryClass); + } + + public function test_guess_repository_fails_when_key_not_found(): void + { + $this->expectExceptionMessage('Repository not found for the key [usersss].'); + + EagerField::make('usersss'); + } +} diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index c5bc6d49c..4cc406e1a 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -164,7 +164,12 @@ protected function ensureLoggedIn(): self protected function logout(): self { - $this->actingAs(null); + if ($this->authenticatedAs instanceof Mockery\MockInterface) { + $this->authenticatedAs->shouldReceive('getRememberToken')->andReturnNull(); + } + + $this->app['auth']->guard()->logout(); + $this->authenticatedAs = null; return $this; } diff --git a/tests/Repositories/PublicRepositoriesTest.php b/tests/Repositories/PublicRepositoriesTest.php new file mode 100644 index 000000000..c7092336a --- /dev/null +++ b/tests/Repositories/PublicRepositoriesTest.php @@ -0,0 +1,36 @@ +set('restify.middleware', [ + 'auth:sanctum' => function ($request, $next) { + abort(403); + }, + ]); + } + + protected function tearDown(): void + { + parent::tearDown(); + + UserRepository::$public = false; + } + + public function test_cannot_access_public_repository(): void + { + $this->logout(); + + $this->getJson(UserRepository::route())->assertForbidden(); + } +} From a77509473e1fc57c6d1ab253637467f58f870999 Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Sun, 24 Jul 2022 13:37:46 +0300 Subject: [PATCH 38/42] Last formatting (#485) * fix: wip * fix: wip --- CHANGELOG.md | 32 ++-- ROADMAP.md | 7 +- docs-v2/content/en/auth/authorization.md | 210 +++++++++++------------ 3 files changed, 119 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa0114bb5..64f46e292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,28 +2,16 @@ All notable changes to `laravel-restify` will be documented in this file -## [7.0.0] 2022-07-20 -- Nested relationship with custom columns (ie: `api/restify/company/include=users.posts[id, name].comments[title]`) -- Restify will do not expose data without a defined policy for the resource -- Performance improvements -- Hidden meta by default -- You don't have to specify the key and the repository that serializes related entities: -```php -// before -// related(): array -return [ - 'posts' => HasMany::make('posts', PostRepository::class), -]; -``` - -```php -// after -// related(): array -return [ - HasMany::make('posts'), -]; -``` - +## [7.0.0] 2022-07-24 +- [x] Adding support for custom ActionLogs (ie `ActionLog::register("project marked active by user Auth::id()", $project->id)`) +- [x] Ensure `$with` loads relationship in `show` requests +- [x] Make sure any action isn't permitted unless the Model Policy exists +- [x] Having a helper method that allow to return data using the repository from a custom controller `PostRepository::withModels(Post::query()->take(5)->get())->include('user')->serializeForShow()` - see `seralizer()` +- [x] Ability to make an endpoint public using a policy method +- [x] Load specific fields for nested relationships (ie: `api/restify/company/include=users.posts[id, name].comments[title]`) +- [x] Load nested for relationships with a nested level higher than 2 (so now you can load any nested level you need `a.b.c.d`) +- [x] Shorter definition of Related fields `HasMany::make('posts')` +- [x] Performance improvements ## [5.0.0] 2021-05-23 - Repositories CRUD + Bulk diff --git a/ROADMAP.md b/ROADMAP.md index ef125a904..ce29bcc62 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,10 @@ - [x] Make sure any action isn't permitted unless the Model Policy exists - [x] Having a helper method that allow to return data using the repository from a custom controller `PostRepository::withModels(Post::query()->take(5)->get())->include('user')->serializeForShow()` - [x] Serialize nested relationships +- [x] Ability to make an endpoint public using a policy method +- [x] Load specific fields for nested relationships +- [x] Load nested for relationships with a nested level higher than 2 +- [x] Shorter definition of Related fields 8.x @@ -32,7 +36,4 @@ ### Features - [ ] Adding a command that lists all Restify registered routes `php artisan restify:routes` -- [ ] Ability to make an endpoint public using a policy method - [ ] UI for Restify -- [ ] Load specific fields for nested relationships -- [ ] Load nested for relationships with a nested level higher than 2 diff --git a/docs-v2/content/en/auth/authorization.md b/docs-v2/content/en/auth/authorization.md index cc3316d05..a027472f3 100644 --- a/docs-v2/content/en/auth/authorization.md +++ b/docs-v2/content/en/auth/authorization.md @@ -50,17 +50,17 @@ protected function gate() This is the first gate to access the Restify repositories. In a real life project, you may allow every authenticated user to have access to repositories, and just after that, using policies you can restrict specific actions. To do so: ```php - Gate::define('viewRestify', function ($user) { - return true; - }); +Gate::define('viewRestify', function ($user) { + return true; +}); ``` If you want to allow unauthenticated users to be authorized to see restify routes, you can nullify the `$user`: ```php - Gate::define('viewRestify', function ($user = null) { - return true; - }); +Gate::define('viewRestify', function ($user = null) { + return true; +}); ``` From this point, it's highly recommended having a policy for each model have exposed via Restify. Otherwise, users may access unauthorized resources, which is not what we want. @@ -158,17 +158,17 @@ For the examples bellow, we will consider PostRepository as being an example. Just after Restify detects the repository class, it will invoke this method, to check if the given user can load this repository at all. You can check if the user is admin for some specific repositories, for example: ```php - // PostPolicy - /** - * Determine whether the user can use restify feature for each CRUD operation. - * So if this is not allowed, all operations will be disabled - * @param User $user - * @return mixed - */ - public function allowRestify(User $user) - { - return $user->isAdmin(); - } +// PostPolicy +/** + * Determine whether the user can use restify feature for each CRUD operation. + * So if this is not allowed, all operations will be disabled + * @param User $user + * @return mixed + */ +public function allowRestify(User $user) +{ + return $user->isAdmin(); +} ``` ### Allow show @@ -190,17 +190,17 @@ POST: /api/restify/posts/{id} // it will give 403 Forbidden status if you don't Definition: ```php - /** - * Determine whether the user can get the model. - * - * @param User $user - * @param Post $model - * @return mixed - */ - public function show(User $user, Post $model) - { - // - } +/** + * Determine whether the user can get the model. + * + * @param User $user + * @param Post $model + * @return mixed + */ +public function show(User $user, Post $model) +{ + // +} ``` ### Allow store @@ -216,16 +216,16 @@ POST: /api/restify/posts Definition: ```php - /** - * Determine whether the user can create models. - * - * @param User $user - * @return mixed - */ - public function store(User $user) - { - // - } +/** + * Determine whether the user can create models. + * + * @param User $user + * @return mixed + */ +public function store(User $user) +{ + // +} ``` ### Allow storeBulk @@ -240,16 +240,16 @@ POST: api/posts/bulk Definition: ```php - /** - * Determine whether the user can create multiple models at once. - * - * @param User $user - * @return mixed - */ - public function storeBulk(User $user) - { - // - } +/** + * Determine whether the user can create multiple models at once. + * + * @param User $user + * @return mixed + */ +public function storeBulk(User $user) +{ + // +} ``` ### Allow update @@ -286,17 +286,17 @@ The `update` method, correspond to the routes: Definition: ```php - /** - * Determine whether the user can update the model. - * - * @param User $user - * @param Post $model - * @return mixed - */ - public function update(User $user, Post $model) - { - // - } +/** + * Determine whether the user can update the model. + * + * @param User $user + * @param Post $model + * @return mixed + */ +public function update(User $user, Post $model) +{ + // +} ``` ### Allow updateBulk @@ -310,17 +310,17 @@ POST: api/restify/posts/bulk/update Definition: ```php - /** - * Determine whether the user can update bulk the model. - * - * @param User $user - * @param Post $model - * @return mixed - */ - public function updateBulk(User $user = null, Post $model) - { - return true; - } +/** + * Determine whether the user can update bulk the model. + * + * @param User $user + * @param Post $model + * @return mixed + */ +public function updateBulk(User $user = null, Post $model) +{ + return true; +} ``` ### Allow delete @@ -336,17 +336,17 @@ DELETE: api/restify/posts/{id} Definition: ```php - /** - * Determine whether the user can delete the model. - * - * @param User $user - * @param Post $model - * @return mixed - */ - public function delete(User $user, Post $model) - { - // - } +/** + * Determine whether the user can delete the model. + * + * @param User $user + * @param Post $model + * @return mixed + */ +public function delete(User $user, Post $model) +{ + // +} ``` ### Allow Attach @@ -369,19 +369,19 @@ POST: api/restify/users/{id}/attach/posts In this case, Restify will guess the policy name, by the related entity, in this case it will be `attachPost`: ```php - // UserPolicy.php - - /** - * Determine if the post could be attached to the user. - * - * @param User $user - * @param Post $model - * @return mixed - */ - public function attachPost(User $user, Post $model) - { - return $user->is($model->creator()->first()); - } +// UserPolicy.php + +/** + * Determine if the post could be attached to the user. + * + * @param User $user + * @param Post $model + * @return mixed + */ +public function attachPost(User $user, Post $model) +{ + return $user->is($model->creator()->first()); +} ``` The `attachPost` method, will be called for each post in part. @@ -406,17 +406,17 @@ POST: api/restify/users/{id}/detach/posts In this case, Restify will guess the policy name, by the related entity, in this case it will be `detachPost`: ```php - /** - * Determine if the post could be attached to the user. - * - * @param User $user - * @param Post $model - * @return mixed - */ - public function attachPost(User $user, Post $model) - { - return $user->is($model->creator()->first()); - } +/** + * Determine if the post could be attached to the user. + * + * @param User $user + * @param Post $model + * @return mixed + */ +public function attachPost(User $user, Post $model) +{ + return $user->is($model->creator()->first()); +} ``` The `detachPost` method, will be called for each post in part. From 646d25f7adbbcc172f965f726b4378f5eb545a4b Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Sun, 24 Jul 2022 21:21:45 +0300 Subject: [PATCH 39/42] =?UTF-8?q?fix:=20[7.x]=20Load=20routes=20from=20the?= =?UTF-8?q?=20application=20service=20provider=20so=20the=20d=E2=80=A6=20(?= =?UTF-8?q?#487)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: [7.x] Load routes from the application service provider so the developer could disable it. * Fix styling * fix: wip Co-authored-by: binaryk --- UPGRADING.md | 2 +- src/Commands/SetupCommand.php | 4 ++-- .../stubs/RestifyServiceProvider.stub | 20 ------------------- src/LaravelRestifyServiceProvider.php | 19 ++++++------------ src/Restify.php | 2 +- src/RestifyApplicationServiceProvider.php | 19 ++++++++++++++++++ 6 files changed, 29 insertions(+), 37 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index 4238002d7..e28509669 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -13,7 +13,7 @@ High impact: - `$defaultPerPage` and `$defaultRelatablePerPage` has a type of `int`, if you override this make sure you add `int` type - `eagerState` method was deleted from the repository, there is no need to call it anymore, the repository will be resolved automatically - `$prefix` property requires a `string` type -- Relations that are present into `include` or `related` will be preloaded, so if you didn't specify a repository to serialize the related relationship, and you're looking for the Eloquent to resolve it, it will do not invoke the `restify.casts.related` cast anymore, instead it'll load the relationship as it. This has a performance reason under the hood. +- Relations that are present into `include` or `related` will be preloaded, so if you didn't specify a repository to serialize the related relationship, and you're looking for the Eloquent to resolve it, it will not invoke the `restify.casts.related` cast anymore, instead it'll load the relationship as it. This has a performance reason under the hood. - Since related relationships will be preloaded, the format of the belongs to will be changed now. If you didn't specify the repository to serialize the `belongsTo` relationship, it'll be serialized as an object, not array anymore: Before: diff --git a/src/Commands/SetupCommand.php b/src/Commands/SetupCommand.php index 17dc318b0..3549d2645 100644 --- a/src/Commands/SetupCommand.php +++ b/src/Commands/SetupCommand.php @@ -53,8 +53,8 @@ protected function registerRestifyServiceProvider() $namespace = Str::replaceLast('\\', '', $this->laravel->getNamespace()); file_put_contents(config_path('app.php'), str_replace( - "{$namespace}\\Providers\EventServiceProvider::class,".PHP_EOL, - "{$namespace}\\Providers\EventServiceProvider::class,".PHP_EOL." {$namespace}\Providers\RestifyServiceProvider::class,".PHP_EOL, + "{$namespace}\\Providers\RouteServiceProvider::class,".PHP_EOL, + "{$namespace}\\Providers\RouteServiceProvider::class,".PHP_EOL." {$namespace}\Providers\RestifyServiceProvider::class,".PHP_EOL, file_get_contents(config_path('app.php')) )); } diff --git a/src/Commands/stubs/RestifyServiceProvider.stub b/src/Commands/stubs/RestifyServiceProvider.stub index bb44ee753..5b26af792 100644 --- a/src/Commands/stubs/RestifyServiceProvider.stub +++ b/src/Commands/stubs/RestifyServiceProvider.stub @@ -7,16 +7,6 @@ use Binaryk\LaravelRestify\RestifyApplicationServiceProvider; class RestifyServiceProvider extends RestifyApplicationServiceProvider { - /** - * Bootstrap any application services. - * - * @return void - */ - public function boot() - { - parent::boot(); - } - /** * Register the Restify gate. * @@ -32,14 +22,4 @@ class RestifyServiceProvider extends RestifyApplicationServiceProvider ]); }); } - - /** - * Register any application services. - * - * @return void - */ - public function register() - { - // - } } diff --git a/src/LaravelRestifyServiceProvider.php b/src/LaravelRestifyServiceProvider.php index 4936691af..679bdbd7d 100644 --- a/src/LaravelRestifyServiceProvider.php +++ b/src/LaravelRestifyServiceProvider.php @@ -2,7 +2,6 @@ namespace Binaryk\LaravelRestify; -use Binaryk\LaravelRestify\Bootstrap\RoutesBoot; use Binaryk\LaravelRestify\Commands\ActionCommand; use Binaryk\LaravelRestify\Commands\BaseRepositoryCommand; use Binaryk\LaravelRestify\Commands\DevCommand; @@ -18,7 +17,6 @@ use Binaryk\LaravelRestify\Filters\RelatedDto; use Binaryk\LaravelRestify\Http\Middleware\RestifyInjector; use Binaryk\LaravelRestify\Repositories\Repository; -use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Http\Kernel; use Illuminate\Support\Facades\App; use Spatie\LaravelPackageTools\Package; @@ -49,24 +47,19 @@ public function configurePackage(Package $package): void ]); } - /** - * @throws BindingResolutionException - */ public function packageBooted(): void { if ($this->app->runningInConsole()) { $this->registerPublishing(); } - /** - * @var Kernel $kernel - */ - $kernel = $this->app->make(Kernel::class); + if (App::runningUnitTests()) { + /** + * @var Kernel $kernel + */ + $kernel = $this->app->make(Kernel::class); - $kernel->pushMiddleware(RestifyInjector::class); - - if (! App::runningUnitTests()) { - app(RoutesBoot::class)->boot(); + $kernel->pushMiddleware(RestifyInjector::class); } $this->app->singleton(RelatedDto::class, fn ($app) => new RelatedDto()); diff --git a/src/Restify.php b/src/Restify.php index 0b40beba6..265693449 100644 --- a/src/Restify.php +++ b/src/Restify.php @@ -87,7 +87,7 @@ public static function repository(string $key): Repository * @var Repository|string $repositoryClass */ if (is_null($repositoryClass = static::repositoryClassForKey($key))) { - throw RepositoryNotFoundException::make($repositoryClass); + throw RepositoryNotFoundException::make($key); } return $repositoryClass::isMock() diff --git a/src/RestifyApplicationServiceProvider.php b/src/RestifyApplicationServiceProvider.php index 1be72889b..a6ffd9977 100644 --- a/src/RestifyApplicationServiceProvider.php +++ b/src/RestifyApplicationServiceProvider.php @@ -2,12 +2,16 @@ namespace Binaryk\LaravelRestify; +use Binaryk\LaravelRestify\Bootstrap\RoutesBoot; use Binaryk\LaravelRestify\Http\Controllers\Auth\ForgotPasswordController; use Binaryk\LaravelRestify\Http\Controllers\Auth\LoginController; use Binaryk\LaravelRestify\Http\Controllers\Auth\RegisterController; use Binaryk\LaravelRestify\Http\Controllers\Auth\ResetPasswordController; use Binaryk\LaravelRestify\Http\Controllers\Auth\VerifyController; +use Binaryk\LaravelRestify\Http\Middleware\RestifyInjector; +use Illuminate\Contracts\Http\Kernel; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; @@ -23,6 +27,7 @@ public function boot() $this->authorization(); $this->repositories(); $this->authRoutes(); + $this->routes(); } /** @@ -105,4 +110,18 @@ protected function authRoutes(): void }); }); } + + protected function routes(): void + { + /** + * @var Kernel $kernel + */ + $kernel = $this->app->make(Kernel::class); + + $kernel->pushMiddleware(RestifyInjector::class); + + if (App::runningInConsole()) { + app(RoutesBoot::class)->boot(); + } + } } From 88f046879a7d8d9275bce95f4eb30aa833b7b74d Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Sun, 24 Jul 2022 22:03:12 +0300 Subject: [PATCH 40/42] fix: [7.x] Loading routes before middleware stack. --- src/Bootstrap/Boot.php | 2 ++ src/RestifyApplicationServiceProvider.php | 4 +--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Bootstrap/Boot.php b/src/Bootstrap/Boot.php index 2e37f4190..40b080308 100644 --- a/src/Bootstrap/Boot.php +++ b/src/Bootstrap/Boot.php @@ -17,6 +17,8 @@ public function boot(): void { RestifyBeforeEach::dispatch($this->request); + ray('is restify'); + ray(isRestify($this->request)); if (isRestify($this->request)) { $this->routesBoot->boot(); } diff --git a/src/RestifyApplicationServiceProvider.php b/src/RestifyApplicationServiceProvider.php index a6ffd9977..2deaa4d4a 100644 --- a/src/RestifyApplicationServiceProvider.php +++ b/src/RestifyApplicationServiceProvider.php @@ -120,8 +120,6 @@ protected function routes(): void $kernel->pushMiddleware(RestifyInjector::class); - if (App::runningInConsole()) { - app(RoutesBoot::class)->boot(); - } + app(RoutesBoot::class)->boot(); } } From 372834efa20ed6f9b8ab950dc01b572528da9665 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Sun, 24 Jul 2022 22:03:19 +0300 Subject: [PATCH 41/42] fix: wip --- src/Bootstrap/Boot.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Bootstrap/Boot.php b/src/Bootstrap/Boot.php index 40b080308..2e37f4190 100644 --- a/src/Bootstrap/Boot.php +++ b/src/Bootstrap/Boot.php @@ -17,8 +17,6 @@ public function boot(): void { RestifyBeforeEach::dispatch($this->request); - ray('is restify'); - ray(isRestify($this->request)); if (isRestify($this->request)) { $this->routesBoot->boot(); } From 426f4c4dd0b39c3f143669a4c3be07b73bdcedca Mon Sep 17 00:00:00 2001 From: binaryk Date: Sun, 24 Jul 2022 19:03:56 +0000 Subject: [PATCH 42/42] Fix styling --- src/RestifyApplicationServiceProvider.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/RestifyApplicationServiceProvider.php b/src/RestifyApplicationServiceProvider.php index 2deaa4d4a..18db75305 100644 --- a/src/RestifyApplicationServiceProvider.php +++ b/src/RestifyApplicationServiceProvider.php @@ -11,7 +11,6 @@ use Binaryk\LaravelRestify\Http\Middleware\RestifyInjector; use Illuminate\Contracts\Http\Kernel; use Illuminate\Filesystem\Filesystem; -use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider;