diff --git a/UPGRADING.md b/UPGRADING.md index 40f43ab4c..7a2189f84 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -6,7 +6,7 @@ Because there are many breaking changes an upgrade is not that easy. There are m - Dropped support for laravel passport - Now you have to explicitly define the `allowRestify` method in the model policy, by default Restify don't allow you to use repositories. -- `viewAny` policy is not used anymore, you can delete it. +- `viewAny` policy isn't used anymore, you can delete it. - The default exception handler is the Laravel one, see `restify.php -> handler` - `fillCallback` signature has changed - By default it will do not allow you to attach `belongsToMany` and `morphToMany` relationships. You will have to add `BelongsToMany` or `MorphToMany` field into your repository diff --git a/config/config.php b/config/config.php index 99d12688a..8833b174d 100644 --- a/config/config.php +++ b/config/config.php @@ -45,7 +45,7 @@ 'password_reset_url' => env('FRONTEND_APP_URL').'/password/reset?token={token}&email={email}', - 'user_verify_url' => env('FRONTEND_APP_URL').'/verify?id={id}&hash={emailHash}', + 'user_verify_url' => env('FRONTEND_APP_URL').'/verify/{id}/{emailHash}', ], /* diff --git a/docs/docs/4.0/auth/auth.md b/docs/docs/4.0/auth/auth.md index e202eb693..22810493e 100644 --- a/docs/docs/4.0/auth/auth.md +++ b/docs/docs/4.0/auth/auth.md @@ -9,10 +9,20 @@ You'll finally enjoy the auth setup (`register`, `login`, `forgot` and `reset pa - Migrate the `personal_access_tokens` table, provided by sanctum. +- Install laravel sanctum. See the docs [here](https://laravel.com/docs/sanctum#installation). You don't need to add `\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,` in your `'api'` middleware group. So you only need to run these 3 commands: + +```shell script +composer require laravel/sanctum +php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" +php artisan migrate +``` + - Make sure your authenticatable entity (usually `App\Models\User`) implements: `Illuminate\Contracts\Auth\Authenticatable` (or simply extends the `Illuminate\Foundation\Auth\User` class as it does into a fresh laravel app.) - Make sure the `App\Models\User` model implements the `Binaryk\LaravelRestify\Contracts\Sanctumable` contract. +- Add `\Laravel\Sanctum\HasApiTokens` trait to your `User` model. + ## Define routes Restify provides you a simple way to add all of your auth routes ready. Simply add in your `routes/api.php`: @@ -137,7 +147,7 @@ This method could look like this: public function sendEmailVerificationNotification() { - $this->notify(new VerifyEmail); + $this->notify(new \Binaryk\LaravelRestify\Notifications\VerifyEmail); } ``` diff --git a/docs/docs/4.0/custom-filters/custom-filters.md b/docs/docs/4.0/custom-filters/custom-filters.md index fc28180d2..716b71199 100644 --- a/docs/docs/4.0/custom-filters/custom-filters.md +++ b/docs/docs/4.0/custom-filters/custom-filters.md @@ -230,3 +230,9 @@ The response will look like this: ] ``` +Along with custom filters, you can also include in the response the primary filters (as matches), by using `?include` query param: + +```http request +/api/restify/posts/filters?include=matches,searchables,sortables +``` + diff --git a/docs/docs/4.0/filtering/filtering.md b/docs/docs/4.0/filtering/filtering.md index d14a7ea2b..eb1b17de2 100644 --- a/docs/docs/4.0/filtering/filtering.md +++ b/docs/docs/4.0/filtering/filtering.md @@ -20,6 +20,14 @@ request: GET: /api/restify/posts?search="Test title" ``` +### Get available searchables + +You can use the following request to available searchable attributes for a repository: + +```http request +/api/restify/posts/filters?only=searchables +``` + ## Match Matching by specific attributes may be useful if you want an exact matching. @@ -187,6 +195,14 @@ The next step is to associate this class with the match key name in your `$match ]; ``` +### Get available matches + +You can use the following request to get all repository matches: + +```http request +/api/restify/posts/filters?only=matches +``` + ## Sort When index query entities, usually we have to sort by specific attributes. This requires the `$sort` configuration: @@ -217,10 +233,22 @@ or with plus sign before the field: GET: /api/restify/posts?sort=+id ``` +### Get available sorts + +You can use the following request to get sortable attributes for a repository: + +```http request +/api/restify/posts/filters?only=sortables +``` + +:::tip All filters +You can use `/api/restify/posts/filters?only=sortables` request, and concatenate: `?only=sortables,matches, searchables` to get all of them at once. +::: + ## Eager loading - aka withs When get a repository index or details about a single entity, often we have to get the related entities (we have access to). -This eager loading is configurable by Restify as follow: +This eager loading is configurable by Restify as following: ```php public static $related = ['posts']; @@ -229,7 +257,7 @@ public static $related = ['posts']; This means that we could use `posts` query for eager loading posts: ```http request -GET: /api/restify/users?with=posts +GET: /api/restify/users?related=posts ``` ## Pagination diff --git a/docs/docs/4.0/quickstart.md b/docs/docs/4.0/quickstart.md index 9ac941b1f..270d03b29 100644 --- a/docs/docs/4.0/quickstart.md +++ b/docs/docs/4.0/quickstart.md @@ -72,7 +72,9 @@ One important configuration is the restify default middlewares: ### Sanctum authorization -Usually you want to authorize your api (allow access only to authenticated users). For this purpose you can simply add another middleware. For the `sanctum`, Restify provides `Binaryk\LaravelRestify\Http\Middleware\RestifySanctumAuthenticate` middleware. +Usually you want to authorize your api (allow access only to authenticated users). For this purpose you can simply add another middleware. For the `sanctum`, Restify provides `Binaryk\LaravelRestify\Http\Middleware\RestifySanctumAuthenticate::class` middleware. Make sure you put this right after `api` middleware. + +You may notice that Restify also use the `EnsureJsonApiHeaderMiddleware` middleware, which enforce you to use the `application/vnd.api+json` Accept header for your API requests. So make sure, even when using Postman (or something else) for making requests, that this `Accept header` is applied. ### Exception Handling diff --git a/docs/docs/4.0/repository-pattern/repository-pattern.md b/docs/docs/4.0/repository-pattern/repository-pattern.md index dc5a2cfb3..69af44b0e 100644 --- a/docs/docs/4.0/repository-pattern/repository-pattern.md +++ b/docs/docs/4.0/repository-pattern/repository-pattern.md @@ -56,10 +56,10 @@ Having this in place you're basically ready for the CRUD actions over posts. You | GET | `/api/restify/posts/{post}` | show | | POST | `/api/restify/posts` | store | | POST | `/api/restify/posts/bulk` | store multiple | -| POST | `/api/restify/posts/bulk/update` | store multiple | -| PATCH | `/api/restify/posts/{post}` | update | -| PUT | `/api/restify/posts/{post}` | update | -| POST | `/api/restify/posts/{post}` | update | +| 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 | | DELETE | `/api/restify/posts/{post}` | destroy | :::tip Update with files As you can see we provide 3 Verbs for the model update (PUT, PATCH, POST), the reason of that @@ -195,14 +195,14 @@ You can customize the `meta` by creating your own `resolveShowMeta` method: } ``` -:::tip Resource property In the previous example we have used the `$this->resource` call, well, keep in mind, that you +:::tip $resource property +In the previous example we have used the `$this->resource` call, well, keep in mind, that you always have access to the current resource in your not static methods of the repository, were the resource is the actual current model. In the case above, the `$this->resource` represents the `Post` model with the `id=1`, because we're looking for the route: `/api/restify/posts/1`. A similar way to get the model is the `$this->model()` method. ::: -Well, a lot of methods to modify the serialization partials, however, you are free to customize the entire response at -once by defining: +As we saw before, there are many ways to partially modify the response (ie separate way to modify meta), however, you are free to customize the entire response at once by defining: ```php // PostRepository.php diff --git a/src/Filter.php b/src/Filter.php index d498e1898..c18b7c08f 100644 --- a/src/Filter.php +++ b/src/Filter.php @@ -6,6 +6,7 @@ use Binaryk\LaravelRestify\Traits\Make; use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Str; use JsonSerializable; abstract class Filter implements JsonSerializable @@ -18,6 +19,8 @@ abstract class Filter implements JsonSerializable public $canSeeCallback; + public static $uriKey; + public function __construct() { $this->booted(); @@ -73,10 +76,31 @@ public function resolve(RestifyRequest $request, $filter) $this->value = $filter; } + /** + * Get the URI key for the filter. + * + * @return string + */ + public static function uriKey() + { + if (property_exists(static::class, 'uriKey') && is_string(static::$uriKey)) { + return static::$uriKey; + } + + $kebabWithoutRepository = Str::kebab(Str::replaceLast('Filter', '', class_basename(get_called_class()))); + + /** + * e.g. UserRepository => users + * e.g. LaravelEntityRepository => laravel-entities. + */ + return Str::plural($kebabWithoutRepository); + } + public function jsonSerialize() { return [ 'class' => static::class, + 'key' => static::uriKey(), 'type' => $this->getType(), 'options' => collect($this->options(app(Request::class)))->map(function ($value, $key) { return is_array($value) ? ($value + ['property' => $key]) : ['label' => $key, 'property' => $value]; diff --git a/src/Filters/MatchFilter.php b/src/Filters/MatchFilter.php new file mode 100644 index 000000000..4335e059b --- /dev/null +++ b/src/Filters/MatchFilter.php @@ -0,0 +1,46 @@ +where($this->column, $value); + } + + public static function makeFromSimple($column, $type): self + { + return tap(new static, function (MatchFilter $filter) use ($column, $type) { + $filter->type = $type; + $filter->column = $column; + }); + } + + public static function makeForRepository(Repository $repository): Collection + { + return collect($repository::getMatchByFields())->map(function ($type, $column) { + return static::makeFromSimple($column, $type); + })->values(); + } + + public function jsonSerialize() + { + return [ + 'class' => static::class, + 'type' => $this->getType(), + 'key' => static::uriKey(), + 'column' => $this->column, + ]; + } +} diff --git a/src/Filters/SearchableFilter.php b/src/Filters/SearchableFilter.php new file mode 100644 index 000000000..753cd5bb0 --- /dev/null +++ b/src/Filters/SearchableFilter.php @@ -0,0 +1,44 @@ +where($this->column, 'LIKE', "%{$value}%"); + } + + public static function makeFromSimple($column): self + { + return tap(new static, function (SearchableFilter $filter) use ($column) { + $filter->column = $column; + }); + } + + public static function makeForRepository(Repository $repository): Collection + { + return collect($repository::getSearchableFields())->map(function ($column) { + return static::makeFromSimple($column); + }); + } + + public function jsonSerialize() + { + return [ + 'class' => static::class, + 'key' => static::uriKey(), + 'column' => $this->column, + ]; + } +} diff --git a/src/Filters/SortableFilter.php b/src/Filters/SortableFilter.php new file mode 100644 index 000000000..aead4026b --- /dev/null +++ b/src/Filters/SortableFilter.php @@ -0,0 +1,44 @@ +orderBy($this->column, $direction); + } + + public static function makeFromSimple($column): self + { + return tap(new static, function (SortableFilter $filter) use ($column) { + $filter->column = $column; + }); + } + + public static function makeForRepository(Repository $repository): Collection + { + return collect($repository::getOrderByFields())->map(function ($column) { + return static::makeFromSimple($column); + }); + } + + public function jsonSerialize() + { + return [ + 'class' => static::class, + 'key' => static::uriKey(), + 'column' => $this->column, + ]; + } +} diff --git a/src/Http/Controllers/RepositoryFilterController.php b/src/Http/Controllers/RepositoryFilterController.php index cdd416e8f..2bf76e61a 100644 --- a/src/Http/Controllers/RepositoryFilterController.php +++ b/src/Http/Controllers/RepositoryFilterController.php @@ -2,7 +2,11 @@ namespace Binaryk\LaravelRestify\Http\Controllers; +use Binaryk\LaravelRestify\Filters\MatchFilter; +use Binaryk\LaravelRestify\Filters\SearchableFilter; +use Binaryk\LaravelRestify\Filters\SortableFilter; use Binaryk\LaravelRestify\Http\Requests\RepositoryFiltersRequest; +use Illuminate\Support\Collection; class RepositoryFilterController extends RepositoryController { @@ -10,6 +14,25 @@ public function __invoke(RepositoryFiltersRequest $request) { $repository = $request->repository(); - return $this->response()->data($repository->availableFilters($request)); + return $this->response()->data( + $repository->availableFilters($request) + // After + ->when($request->has('include'), function (Collection $collection) use ($repository, $request) { + return $collection->merge( + collect(str_getcsv($request->input('include')))->map(fn ($key) => collect([ + SearchableFilter::uriKey() => SearchableFilter::class, + MatchFilter::uriKey() => MatchFilter::class, + SortableFilter::uriKey() => SortableFilter::class, + ])->get($key))->flatMap(fn ($filterable) => $filterable::makeForRepository($repository)) + ); + }) + ->when($request->has('only'), function (Collection $collection) use ($repository, $request) { + return collect(str_getcsv($request->input('only')))->map(fn ($key) => collect([ + SearchableFilter::uriKey() => SearchableFilter::class, + MatchFilter::uriKey() => MatchFilter::class, + SortableFilter::uriKey() => SortableFilter::class, + ])->get($key))->flatMap(fn ($filterable) => $filterable::makeForRepository($repository)); + }) + ); } } diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index dff1b3cd3..d1114971a 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -29,6 +29,7 @@ use Illuminate\Http\Resources\DelegatesToResource; use Illuminate\Pagination\AbstractPaginator; use Illuminate\Routing\Router; +use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -177,7 +178,7 @@ public function model() } /** - * Get the URI key for the resource. + * Get the URI key for the repository. * * @return string */ @@ -522,6 +523,12 @@ public function resolveRelationships($request): array collect(str_getcsv($request->input('related'))) ->filter(fn ($relation) => in_array($relation, static::getRelated())) ->each(function ($relation) use ($request, $withs) { + if (Str::contains($relation, '.')) { + $this->resource->loadMissing($relation); + + return $withs->put($key = Str::before($relation, '.'), Arr::get($this->resource->relationsToArray(), $key)); + } + $paginator = $this->resource->relationLoaded($relation) ? $this->resource->{$relation} : $this->resource->{$relation}(); diff --git a/tests/Controllers/RepositoryFilterControllerTest.php b/tests/Controllers/RepositoryFilterControllerTest.php index fda845f00..10f543419 100644 --- a/tests/Controllers/RepositoryFilterControllerTest.php +++ b/tests/Controllers/RepositoryFilterControllerTest.php @@ -6,6 +6,7 @@ use Binaryk\LaravelRestify\Tests\Fixtures\Post\CreatedAfterDateFilter; use Binaryk\LaravelRestify\Tests\Fixtures\Post\InactiveFilter; use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; +use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostRepository; use Binaryk\LaravelRestify\Tests\Fixtures\Post\SelectCategoryFilter; use Binaryk\LaravelRestify\Tests\Fixtures\User\UserRepository; use Binaryk\LaravelRestify\Tests\IntegrationTest; @@ -14,11 +15,77 @@ class RepositoryFilterControllerTest extends IntegrationTest { public function test_can_get_available_filters() { - $response = $this - ->withoutExceptionHandling() - ->getJson('posts/filters'); + $this->getJson('posts/filters')->assertJsonCount(4, 'data'); + } + + public function test_available_filters_contains_matches_sortables_searches() + { + PostRepository::$match = [ + 'title' => 'text', + ]; + + PostRepository::$sort = [ + 'title', + ]; + + PostRepository::$search = [ + 'id', + 'title', + ]; + + $response = $this->getJson('posts/filters?include=matches,sortables,searchables') + // 5 custom filters + // 1 match filter + // 1 sort + // 2 searchable + ->assertJsonCount(8, 'data'); + + $this->assertSame( + $response->json('data.4.key'), 'matches' + ); + $this->assertSame( + $response->json('data.4.column'), 'title' + ); + $this->assertSame( + $response->json('data.5.key'), 'sortables' + ); + $this->assertSame( + $response->json('data.5.column'), 'title' + ); + $this->assertSame( + $response->json('data.6.key'), 'searchables' + ); + $this->assertSame( + $response->json('data.6.column'), 'id' + ); + } + + public function test_available_filters_returns_only_matches_sortables_searches() + { + PostRepository::$match = [ + 'title' => 'text', + ]; + + PostRepository::$sort = [ + 'title', + ]; + + PostRepository::$search = [ + 'id', + 'title', + ]; + + $response = $this->getJson('posts/filters?only=matches,sortables,searchables') + ->assertJsonCount(4, 'data'); + + $response = $this->getJson('posts/filters?only=matches') + ->assertJsonCount(1, 'data'); + + $response = $this->getJson('posts/filters?only=sortables') + ->assertJsonCount(1, 'data'); - $this->assertCount(4, $response->json('data')); + $response = $this->getJson('posts/filters?only=searchables') + ->assertJsonCount(2, 'data'); } public function test_value_filter_doesnt_require_value() diff --git a/tests/Controllers/RepositoryIndexControllerTest.php b/tests/Controllers/RepositoryIndexControllerTest.php index a93e9edef..6389d757f 100644 --- a/tests/Controllers/RepositoryIndexControllerTest.php +++ b/tests/Controllers/RepositoryIndexControllerTest.php @@ -2,8 +2,11 @@ namespace Binaryk\LaravelRestify\Tests\Controllers; +use Binaryk\LaravelRestify\Tests\Fixtures\Company\Company; +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\User\User; use Binaryk\LaravelRestify\Tests\IntegrationTest; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -91,6 +94,27 @@ public function test_repository_with_relations() $this->assertArrayNotHasKey('user', $response->json('data.0.attributes')); } + public function test_repository_with_deep_relations() + { + CompanyRepository::$related = ['users.posts']; + + tap(factory(Company::class)->create(), function (Company $company) { + tap($company->users()->create( + array_merge(factory(User::class)->make()->toArray(), [ + 'password' => 'secret', + ]) + ), function (User $user) { + factory(Post::class)->create(['user_id' => $user->id]); + }); + }); + + $response = $this->getJson(CompanyRepository::uriKey().'?related=users.posts') + ->assertOk(); + + $this->assertCount(1, $response->json('data.0.relationships.users')); + $this->assertCount(1, $response->json('data.0.relationships.users.0.posts')); + } + public function test_paginated_repository_with_relations() { PostRepository::$related = ['user']; diff --git a/tests/Fixtures/Company/CompanyRepository.php b/tests/Fixtures/Company/CompanyRepository.php index f7cf375d9..dc6d7a8d3 100644 --- a/tests/Fixtures/Company/CompanyRepository.php +++ b/tests/Fixtures/Company/CompanyRepository.php @@ -12,6 +12,10 @@ class CompanyRepository extends Repository { public static $model = Company::class; + public static $related = [ + 'users', + ]; + public function fields(RestifyRequest $request) { return [