diff --git a/docs/docs/4.0/auth/profile.md b/docs/docs/4.0/auth/profile.md index bfbf76e37..410433bc0 100644 --- a/docs/docs/4.0/auth/profile.md +++ b/docs/docs/4.0/auth/profile.md @@ -2,19 +2,22 @@ [[toc]] -To ensure you can get your profile, you should add the `Authenticate` middleware to your restify, this can be easily done by using the `Binaryk\LaravelRestify\Http\Middleware\RestifySanctumAuthenticate::class` into your `restify.middleware` [configuration file](../quickstart.html#configurations); +To ensure you can get your profile, you should add the `Authenticate` middleware to your restify, this can be easily +done by using the `Binaryk\LaravelRestify\Http\Middleware\RestifySanctumAuthenticate::class` into +your `restify.middleware` [configuration file](../quickstart.html#configurations); Laravel Restify expose the user profile via `GET: /api/restify/profile` endpoint. ## Get profile using repository -When retrieving the user profile, by default it is serialized using the `UserRepository` if there is once (Restify will find the repository based on the `User` model). +When retrieving the user profile, by default it is serialized using the `UserRepository` if there is once (Restify will +find the repository based on the `User` model). ```http request GET: /api/restify/profile ``` -This is what we have for a basic profile: +This is what we have for a basic profile: ```json { @@ -51,7 +54,8 @@ public function fields(RestifyRequest $request) } ``` -Since the profile is resolved using the UserRepository, you can benefit from the power of related entities. For example, if you want to return user roles: +Since the profile is resolved using the UserRepository, you can benefit from the power of related entities. For example, +if you want to return user roles: ```php //UserRepository @@ -61,7 +65,8 @@ public static $related = [ ]; ``` -And make sure the `User` model, has this method, which returns a relationship from another table, or you can simply return an array: +And make sure the `User` model, has this method, which returns a relationship from another table, or you can simply +return an array: ```php //User.php @@ -81,6 +86,7 @@ Let's get the profile now, using the `roles` relationship: ```http request GET: /api/restify/profile?related=roles ``` + The result will look like this: ```json @@ -108,7 +114,8 @@ The result will look like this: ### Without repository -In some cases, you may choose to not use the repository for the profile serialization. In such cases you should add the trait `Binaryk\LaravelRestify\Repositories\UserProfile` into your `UserRepository`: +In some cases, you may choose to not use the repository for the profile serialization. In such cases you should add the +trait `Binaryk\LaravelRestify\Repositories\UserProfile` into your `UserRepository`: ```php // UserProfile @@ -127,7 +134,7 @@ class UserRepository extends Repository In this case, the profile will return the model directly: -:::warn Relations +:::warning Relations Note that when you're not using the repository, the `?related` will do not work anymore. ::: @@ -150,10 +157,10 @@ And you will get: } ``` -### Conditionally use repository - -In rare cases you may want to utilize the repository only for non admin users for example, to ensure you serialize specific fields for the users: +### Conditionally use repository +In rare cases you may want to utilize the repository only for non admin users for example, to ensure you serialize +specific fields for the users: ```php use Binaryk\LaravelRestify\Fields\Field; @@ -190,7 +197,8 @@ This way you instruct Restify to only use the repository for users who are admin ## Update Profile using repository -By default, Restify will validate, and fill only fields presented in your `UserRepository` for updating the user profile. Let's get as an example the following repository fields: +By default, Restify will validate, and fill only fields presented in your `UserRepository` for updating the user +profile. Let's get as an example the following repository fields: ```php // UserRepository @@ -215,8 +223,9 @@ If we will try to call the `PUT` method to update the profile without data: We will get back `4xx` validation: -:::warn Accept header -If you test it via Postman (or other HTTP client), make sure you always pass the `Accept` header `application/json`. This will instruct Laravel to return you back json formatted data: +:::warning +Accept header If you test it via Postman (or other HTTP client), make sure you always pass the `Accept` +header `application/json`. This will instruct Laravel to return you back json formatted data: ::: ```json @@ -229,6 +238,7 @@ If you test it via Postman (or other HTTP client), make sure you always pass the } } ``` + So we have to populate the user `name` in the payload: ```json @@ -237,7 +247,7 @@ So we have to populate the user `name` in the payload: } ``` -Since the payload is valid now, Restify will update the user profile (name in our case): +Since the payload is valid now, Restify will update the user profile (name in our case): ```json { @@ -258,7 +268,8 @@ Since the payload is valid now, Restify will update the user profile (name in ou ### Update without repository -If you [don't use the repository](./#get-profile-using-repository) for the user profile, Restify will update only `fillable` user attributes present in the request payload: `$request->only($user->getFillable())`. +If you [don't use the repository](./#get-profile-using-repository) for the user profile, Restify will update +only `fillable` user attributes present in the request payload: `$request->only($user->getFillable())`. ```http request PUT: /api/restify/profile @@ -272,7 +283,7 @@ Payload: } ```` -The response will be the updated user: +The response will be the updated user: ```json { @@ -289,7 +300,7 @@ The response will be the updated user: ## User avatar -To prepare your users for avatars, you should add the `avatar` column in your users table: +To prepare your users for avatars, you can add the `avatar` column in your users table: ```php // Migration @@ -301,23 +312,60 @@ public function up() } ``` -Now you can use the Restify endpoints to update the avatar: +Not you should specify in the user repository that user has avatar file: + +```php +use Binaryk\LaravelRestify\Fields\Image; + +public function fields(RestifyRequest $request) +{ + return [ + Field::make('name')->rules('required'), + + Image::make('avatar')->storeAs('avatar.jpg') + ]; +} +``` + +Now you can use the Restify profile update, and give the avatar as an image. + +:::warning Post request + +You cannot upload file using PUT or PATCH verbs, so we should use POST request. +::: ```http request -POST: /api/restify/profile/avatar +POST: /api/restify/profile +``` + +The payload should be a form-data, with an image under `avatar` key: + +```json +{ + "avatar": "binary image in form data request" +} ``` -The payload should be a form-data, with an image under `avatar` key. +If you have to customize path or disk of the storage file, check the [image field](../repository-pattern/field.html#file-fields) + +### Avatar without repository + +If you don't use the repository for updating the user profile, Restify provides a separate endpoint for updating the avatar. + +```http request +POST: api/restify/profile/avatar +``` -The default path for storing avatar is: `/avatars/{user_key}/`. +The default path for storing avatar is: `/avatars/{user_key}/`, and it uses by default the `public` disk. You can modify that by modifying property in a `boot` method of any service provider: ```php -Binaryk\LaravelRestify\Http\Requests\ProfileAvatarRequest::$path +Binaryk\LaravelRestify\Http\Requests\ProfileAvatarRequest::$path = 'users'; +Binaryk\LaravelRestify\Http\Requests\ProfileAvatarRequest::$disk = 's3'; ``` -Or if you need the request to make the path: +Or if you need the request to make the path: ```php Binaryk\LaravelRestify\Http\Requests\ProfileAvatarRequest::usingPath(function(Illuminate\Http\Request $request) { diff --git a/docs/docs/4.0/repository-pattern/field.md b/docs/docs/4.0/repository-pattern/field.md index f0c1f4b50..be67de1b1 100644 --- a/docs/docs/4.0/repository-pattern/field.md +++ b/docs/docs/4.0/repository-pattern/field.md @@ -1,5 +1,7 @@ # Field +[[toc]] + Field is basically the model attribute representation. Each Field generally extends the `Binaryk\LaravelRestify\Fields\Field` class from the Laravel Restify. This class ships a variety of mutators, interceptors, validators chaining methods you can use for defining your attribute. @@ -198,7 +200,158 @@ Field::new('token')->value(Str::random(32))->hidden(); # Variations -Bellow we have a list of fields used for the related resources. +## File fields + +To illustrate the behavior of Restify file upload fields, let's assume our application's users can upload "avatar photos" to their account. So, our users database table will have an `avatar` column. This column will contain the path to the profile on disk, or, when using a cloud storage provider such as Amazon S3, the profile photo's path within its "bucket". + +### Defining the field + +Next, let's attach the file field to our `UserRepository`. In this example, we will create the field and instruct it to store the underlying file on the `public` disk. This disk name should correspond to a disk name in your `filesystems` configuration file: + +```php +use Binaryk\LaravelRestify\Fields\File; + +public function fields(RestifyRequest $request) +{ + return [ + File::make('avatar')->disk('public') + ]; +} +``` + +### How Files Are Stored + +When a file is uploaded using this field, Restify will use Laravel's [Filesystem integration](https://laravel.com/docs/filesystem) to store the file on the disk of your choosing with a randomly generated filename. Once the file is stored, Restify will store the relative path to the file in the file field's underlying database column. + +To illustrate the default behavior of the `File` field, let's take a look at an equivalent route that would store the file in the same way: + +```php +use Illuminate\Http\Request; + +Route::post('/avatar', function (Request $request) { + $path = $request->avatar->store('/', 'public'); + + $request->user()->update([ + 'avatar' => $path, + ]); +}); +``` + +If you are using the `public` disk with the `local` driver, you should run the `php artisan storage:link` Artisan command to create a symbolic link from `public/storage` to `storage/app/public`. To learn more about file storage in Laravel, check out the [Laravel file storage documentation](https://laravel.com/docs/filesystem). + +### Image + +The `Image` field behaves exactly like the `File` field; however, it will instruct Restify to only accept mimetypes of type `image/*` for it: + +```php +Image::make('avatar')->storeAs('avatar.jpg') +``` + +### Storing Metadata + +In addition to storing the path to the file within the storage system, you may also instruct Restify to store the original client filename and its size (in bytes). You may accomplish this using the `storeOriginalName` and `storeSize` methods. Each of these methods accept the name of the column you would like to store the file information: + +```php +Image::make('avatar') + ->storeOriginalName('avatar_original') + ->storeSize('avatar_size') + ->storeAs('avatar.jpg') +``` + +The image above will store the file, with name `avatar.jpg` in the `avatar` column, the file original name into `avatar_original` column and file size in bytes under `avatar_size` column (only if these columns are fillable on your model). + +### Pruning & Deletion + +File fields are deletable by default, so considering the following field definition: + +```php +File::make('avatar') +``` +You have a request to delete the avatar of the user with the id 1: + +```http request +DELETE: api/restify/users/1/field/avatar +``` + +You can override this behavior by using the `deletable` method: + +```php +File::make('Photo')->disk('public')->deletable(false) +``` + +So now the field will do not be deletable anymore. + +### Customizing File Storage + +Previously we learned that, by default, Restify stores the file using the `store` method of the `Illuminate\Http\UploadedFile` class. However, you may fully customize this behavior based on your application's needs. + +#### Customizing The Name / Path + +If you only need to customize the name or path of the stored file on disk, you may use the `path` and `storeAs` methods of the `File` field: + +```php +use Illuminate\Http\Request; + +File::make('avatar') + ->disk('s3') + ->path($request->user()->id.'-attachments') + ->storeAs(function (Request $request) { + return sha1($request->attachment->getClientOriginalName()); + }), +``` + +#### Customizing The Entire Storage Process + +However, if you would like to take **total** control over the file storage logic of a field, you may use the `store` method. The `store` method accepts a callable which receives the incoming HTTP request and the model instance associated with the request: + +```php +use Illuminate\Http\Request; + +File::make('avatar') + ->store(function (Request $request, $model) { + return [ + 'attachment' => $request->attachment->store('/', 's3'), + 'attachment_name' => $request->attachment->getClientOriginalName(), + 'attachment_size' => $request->attachment->getSize(), + ]; + }), +``` + +As you can see in the example above, the `store` callback is returning an array of keys and values. These key / value pairs are mapped onto your model instance before it is saved to the database, allowing you to update one or many of the model's database columns after your file is stored. + +#### Storeables + +Of course, performing all of your file storage logic within a Closure can cause your resource to become bloated. For that reason, Restify allows you to pass an "Storable" class to the `store` method: + +```php +File::make('avatar')->store(AvatarStore::class), +``` + +The storable class should be a simple PHP class and extends the `Binaryk\LaravelRestify\Repositories\Storable` contract: + +```php + $request->file('avatar')->storeAs('/', 'avatar.jpg', 'customDisk') + ]; + } +} +``` + +:::tip Command +You can use the `php artisan restify:store AvatarStore` command to generate a store file. +::: ## BelongsTo diff --git a/routes/api.php b/routes/api.php index 99fe036ef..5dad31bdf 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ option('force')) { + return false; + } + } + + /** + * Build the class with the given name. + * This method should return the file class content. + * + * @param string $name + * @return string + * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException + */ + protected function buildClass($name) + { + if (false === Str::endsWith($name, 'Store')) { + $name .= 'Store'; + } + + return tap(parent::buildClass($name), function ($stub) use ($name) { + return str_replace(['{{ attribute }}', '{{ query }}'], Str::snake( + Str::beforeLast(class_basename($name), 'Store') + ), $stub); + }); + } + + protected function getStub() + { + return __DIR__.'/stubs/store.stub'; + } + + protected function getPath($name) + { + if (false === Str::endsWith($name, 'Store')) { + $name .= 'Store'; + } + + return parent::getPath($name); + } + + protected function getDefaultNamespace($rootNamespace) + { + return $rootNamespace.'\Restify\Stores'; + } +} diff --git a/src/Commands/stubs/store.stub b/src/Commands/stubs/store.stub new file mode 100644 index 000000000..79cd29800 --- /dev/null +++ b/src/Commands/stubs/store.stub @@ -0,0 +1,17 @@ + $request->file('avatar')->storeAs('/', 'avatar.jpg', 'customDisk') + ]; + } +} diff --git a/src/Contracts/Deletable.php b/src/Contracts/Deletable.php new file mode 100644 index 000000000..9673ec632 --- /dev/null +++ b/src/Contracts/Deletable.php @@ -0,0 +1,53 @@ +acceptedTypes = $acceptedTypes; + + return $this; + } +} diff --git a/src/Fields/Concerns/Deletable.php b/src/Fields/Concerns/Deletable.php new file mode 100644 index 000000000..e6f8d96e9 --- /dev/null +++ b/src/Fields/Concerns/Deletable.php @@ -0,0 +1,94 @@ +deleteCallback = $deleteCallback; + + return $this; + } + + /** + * Specify if the underlying file is able to be deleted. + * + * @param bool $deletable + * @return $this + */ + public function deletable($deletable = true): DeletableContract + { + $this->deletable = $deletable; + + return $this; + } + + /** + * Determine if the underlying file should be pruned when the resource is deleted. + * + * @return bool + */ + public function isPrunable(): bool + { + return $this->prunable; + } + + /** + * Determine if the underlying file should be pruned when the resource is deleted. + * + * @return bool + */ + public function isDeletable(): bool + { + return $this->deletable; + } + + /** + * Specify if the underlying file should be pruned when the resource is deleted. + * + * @param bool $prunable + * @return $this + */ + public function prunable($prunable = true): DeletableContract + { + $this->prunable = $prunable; + + return $this; + } + + public function getDeleteCallback(): ?Closure + { + return $this->deleteCallback; + } +} diff --git a/src/Fields/Concerns/FileStorable.php b/src/Fields/Concerns/FileStorable.php new file mode 100644 index 000000000..39f0b7238 --- /dev/null +++ b/src/Fields/Concerns/FileStorable.php @@ -0,0 +1,78 @@ +disk = $disk; + + return $this; + } + + /** + * Set the file's storage path. + * + * @param string $path + * @return $this + */ + public function path($path) + { + $this->storagePath = $path; + + return $this; + } + + /** + * Get the disk that the field is stored on. + * + * @return string|null + */ + public function getStorageDisk() + { + return $this->disk; + } + + /** + * Get the path that the field is stored at on disk. + * + * @return string|null + */ + public function getStorageDir() + { + return $this->storagePath; + } + + /** + * Get the full path that the field is stored at on disk. + * + * @return string|null + */ + public function getStoragePath() + { + throw new RuntimeException('You must implement getStoragePath method for deleting uploaded files.'); + } +} diff --git a/src/Fields/Field.php b/src/Fields/Field.php index 443e54e7f..8e129c0b4 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -238,7 +238,7 @@ public function fillAttribute(RestifyRequest $request, $model, int $bulkRow = nu ); $this->fillAttributeFromValue( - $request, $model, $this->label ?? $this->attribute, $bulkRow + $request, $model, $this->label ?? $this->attribute ); return $this; @@ -294,7 +294,7 @@ protected function fillAttributeFromCallback(RestifyRequest $request, $model, $a protected function fillAttributeFromValue(RestifyRequest $request, $model, $attribute) { if (! isset($this->valueCallback)) { - return; + return $this; } $model->{$attribute} = is_callable($this->valueCallback) @@ -502,6 +502,9 @@ public function resolveForIndex($repository, $attribute = null) public function resolve($repository, $attribute = null) { $this->repository = $repository; + + $attribute = $attribute ?? $this->attribute; + if ($attribute === 'Computed') { $this->value = call_user_func($this->computedCallback, $repository); diff --git a/src/Fields/FieldCollection.php b/src/Fields/FieldCollection.php index f6be9dd74..27f916d86 100644 --- a/src/Fields/FieldCollection.php +++ b/src/Fields/FieldCollection.php @@ -120,4 +120,15 @@ public function setRepository(Repository $repository): self { return $this->each(fn (Field $field) => $field->setRepository($repository)); } + + public function findFieldByAttribute($attribute, $default = null) + { + foreach ($this->items as $field) { + if (isset($field->attribute) && $field->attribute === $attribute) { + return $field; + } + } + + return null; + } } diff --git a/src/Fields/File.php b/src/Fields/File.php new file mode 100644 index 000000000..94dbc2010 --- /dev/null +++ b/src/Fields/File.php @@ -0,0 +1,245 @@ +prepareStorageCallback(); + + $this->delete(function ($request, $model, $disk, $path) { + if ($file = $this->value ?? $model->{$this->attribute}) { + Storage::disk($this->getStorageDisk())->delete($file); + + return $this->columnsThatShouldBeDeleted(); + } + }); + } + + /** + * Specify the callback or the name that should be used to determine the file's storage name. + * + * @param callable|string $storeAsCallback + * @return $this + */ + public function storeAs($storeAs): self + { + $this->storeAs = $storeAs; + + return $this; + } + + /** + * Prepare the storage callback. + * + * @param callable|null $storageCallback + * @return void + */ + protected function prepareStorageCallback(callable $storageCallback = null): void + { + $this->storageCallback = $storageCallback ?? function ($request, $model) { + return $this->mergeExtraStorageColumns($request, [ + $this->attribute => $this->storeFile($request, $this->attribute), + ]); + }; + } + + /** + * Specify the column where the file's original name should be stored. + * + * @param string $column + * @return $this + */ + public function storeOriginalName($column) + { + $this->originalNameColumn = $column; + + return $this; + } + + /** + * Specify the column where the file size should be stored. + * + * @param string $column + * @return $this + */ + public function storeSize($column) + { + $this->sizeColumn = $column; + + return $this; + } + + protected function storeFile(Request $request, string $requestAttribute) + { + if (! $this->storeAs) { + return $request->file($requestAttribute)->store($this->getStorageDir(), $this->getStorageDisk()); + } + + return $request->file($requestAttribute)->storeAs( + $this->getStorageDir(), + is_callable($this->storeAs) ? call_user_func($this->storeAs, $request) : $this->storeAs, + $this->getStorageDisk() + ); + } + + /** + * Specify the callback that should be used to store the file. + * + * @param callable|Storable $storageCallback + * @return $this + */ + public function store($storageCallback): self + { + $this->storageCallback = is_subclass_of($storageCallback, Storable::class) + ? [app($storageCallback), 'handle'] + : $storageCallback; + + return $this; + } + + /** + * Merge the specified extra file information columns into the storable attributes. + * + * @param \Illuminate\Http\Request $request + * @param array $attributes + * @return array + */ + protected function mergeExtraStorageColumns($request, array $attributes): array + { + $file = $request->file($this->attribute); + + if ($this->originalNameColumn) { + $attributes[$this->originalNameColumn] = $file->getClientOriginalName(); + } + + if ($this->sizeColumn) { + $attributes[$this->sizeColumn] = $file->getSize(); + } + + return $attributes; + } + + /** + * Get an array of the columns that should be deleted and their values. + * + * @return array + */ + protected function columnsThatShouldBeDeleted(): array + { + $attributes = [$this->attribute => null]; + + if ($this->originalNameColumn) { + $attributes[$this->originalNameColumn] = null; + } + + if ($this->sizeColumn) { + $attributes[$this->sizeColumn] = null; + } + + return $attributes; + } + + public function fillAttribute(RestifyRequest $request, $model, int $bulkRow = null) + { + if (is_null($file = $request->file($this->attribute)) || ! $file->isValid()) { + return $this; + } + + if ($this->isPrunable()) { + // Delete old file if exists. +// return function () use ($model, $request) { + call_user_func( + $this->deleteCallback, + $request, + $model, + $this->getStorageDisk(), + $this->getStoragePath() + ); +// }; + } + + $result = call_user_func( + $this->storageCallback, + $request, + $model, + $this->attribute, + $this->disk, + $this->storagePath + ); + + if ($result === true) { + return $this; + } + + if ($result instanceof Closure) { + return $result; + } + + if (! is_array($result)) { + return $model->{$attribute} = $result; + } + + foreach ($result as $key => $value) { + if ($model->isFillable($key)) { + $model->{$key} = $value; + } + } + + return $this; + } + + /** + * Get the full path that the field is stored at on disk. + * + * @return string|null + */ + public function getStoragePath() + { + return $this->value; + } +} diff --git a/src/Fields/Image.php b/src/Fields/Image.php new file mode 100644 index 000000000..a75a22c18 --- /dev/null +++ b/src/Fields/Image.php @@ -0,0 +1,33 @@ +acceptedTypes('image/*'); + } + + public function resolveForShow($repository, $attribute = null) + { + parent::resolveForShow($repository, $attribute); + + $this->value = Storage::disk($this->getStorageDisk())->url($this->value); + + return $this; + } + + public function resolveForIndex($repository, $attribute = null) + { + parent::resolveForIndex($repository, $attribute); + + $this->value = Storage::disk($this->getStorageDisk())->url($this->value); + + return $this; + } +} diff --git a/src/Http/Controllers/Concerns/DeletesFields.php b/src/Http/Controllers/Concerns/DeletesFields.php new file mode 100644 index 000000000..ebe479971 --- /dev/null +++ b/src/Http/Controllers/Concerns/DeletesFields.php @@ -0,0 +1,24 @@ +newRepositoryWith($model)) + ->collectFields($request) + ->whereInstanceOf(Deletable::class) + ->filter(fn (Field $field) => $field instanceof Deletable) + ->filter(fn (Deletable $field) => $field->isPrunable()) + ->resolve($repository) + ->each(function ($field) use ($request, $model) { + DeleteField::forRequest($request, $field, $model)->save(); + }); + } +} diff --git a/src/Http/Controllers/FieldDestroyController.php b/src/Http/Controllers/FieldDestroyController.php new file mode 100644 index 000000000..72522e1ff --- /dev/null +++ b/src/Http/Controllers/FieldDestroyController.php @@ -0,0 +1,38 @@ +newRepositoryWith( + $model = $request->findModelQuery()->firstOrFail() + ); + + $repository->authorizeToUpdate($request); + + $field = $repository->collectFields($request) + ->whereInstanceOf(Deletable::class) + ->filter(fn (Deletable $field) => $field->isDeletable()) + ->resolve($repository) + ->findFieldByAttribute($request->field, function () { + abort(404); + }); + + if (is_null($field)) { + abort(404); + } + + DeleteField::forRequest( + $request, $field, $repository->resource + )->save(); + + return response()->noContent(); + } +} diff --git a/src/Http/Controllers/ProfileAvatarController.php b/src/Http/Controllers/ProfileAvatarController.php index 6ab27055c..81f7940cf 100644 --- a/src/Http/Controllers/ProfileAvatarController.php +++ b/src/Http/Controllers/ProfileAvatarController.php @@ -18,7 +18,7 @@ public function __invoke(ProfileAvatarRequest $request) $path = is_callable(ProfileAvatarRequest::$pathCallback) ? call_user_func(ProfileAvatarRequest::$pathCallback, $request) : $request::$path; - $path = $request->file($request::$userAvatarAttribute)->store($path, 'public'); + $path = $request->file($request::$userAvatarAttribute)->store($path, ProfileAvatarRequest::disk()); $user->{$request::$userAvatarAttribute} = $path; $user->save(); diff --git a/src/Http/Controllers/ProfileController.php b/src/Http/Controllers/ProfileController.php index 4f20196df..a8ecd4aaf 100644 --- a/src/Http/Controllers/ProfileController.php +++ b/src/Http/Controllers/ProfileController.php @@ -3,6 +3,7 @@ namespace Binaryk\LaravelRestify\Http\Controllers; use Binaryk\LaravelRestify\Http\Requests\ProfileAvatarRequest; +use Binaryk\LaravelRestify\Http\Requests\RepositoryShowRequest; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Services\Search\RepositorySearchService; @@ -10,7 +11,7 @@ class ProfileController extends RepositoryController { - public function __invoke(RestifyRequest $request) + public function __invoke(RepositoryShowRequest $request) { if ($repository = $this->guessRepository($request)) { return $repository->serializeForShow($request); diff --git a/src/Http/Controllers/ProfileUpdateController.php b/src/Http/Controllers/ProfileUpdateController.php index c3276967f..d0efb12b6 100644 --- a/src/Http/Controllers/ProfileUpdateController.php +++ b/src/Http/Controllers/ProfileUpdateController.php @@ -2,6 +2,7 @@ namespace Binaryk\LaravelRestify\Http\Controllers; +use Binaryk\LaravelRestify\Http\Requests\RepositoryShowRequest; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\Support\Facades\Auth; @@ -9,7 +10,7 @@ class ProfileUpdateController extends RepositoryController { - public function __invoke(RestifyRequest $request) + public function __invoke(RepositoryShowRequest $request) { $user = $request->user(); diff --git a/src/Http/Controllers/RepositoryDestroyController.php b/src/Http/Controllers/RepositoryDestroyController.php index 6bd2eafd6..2c78e4f52 100644 --- a/src/Http/Controllers/RepositoryDestroyController.php +++ b/src/Http/Controllers/RepositoryDestroyController.php @@ -2,16 +2,21 @@ namespace Binaryk\LaravelRestify\Http\Controllers; +use Binaryk\LaravelRestify\Http\Controllers\Concerns\DeletesFields; use Binaryk\LaravelRestify\Http\Requests\RepositoryDestroyRequest; class RepositoryDestroyController extends RepositoryController { + use DeletesFields; + public function __invoke(RepositoryDestroyRequest $request) { $repository = $request->newRepositoryWith( - $request->findModelQuery()->firstOrFail() + $model = $request->findModelQuery()->firstOrFail() )->allowToDestroy($request); + $this->deleteFields($request, $model); + return $repository->destroy($request, request('repositoryId')); } } diff --git a/src/Http/Requests/ProfileAvatarRequest.php b/src/Http/Requests/ProfileAvatarRequest.php index 5d72a981d..fe479d616 100644 --- a/src/Http/Requests/ProfileAvatarRequest.php +++ b/src/Http/Requests/ProfileAvatarRequest.php @@ -11,11 +11,10 @@ class ProfileAvatarRequest extends RestifyRequest */ public static $pathCallback; - /** - * @var string - */ public static string $path = 'avatars'; + public static string $disk = 'public'; + /** * @var string */ @@ -25,4 +24,14 @@ public static function usingPath(callable $pathCallback) { static::$pathCallback = $pathCallback; } + + public static function usingDisk(string $disk = 'public') + { + static::$disk = $disk; + } + + public static function disk(): string + { + return static::$disk; + } } diff --git a/src/LaravelRestifyServiceProvider.php b/src/LaravelRestifyServiceProvider.php index bc64ee438..636e72400 100644 --- a/src/LaravelRestifyServiceProvider.php +++ b/src/LaravelRestifyServiceProvider.php @@ -12,6 +12,7 @@ use Binaryk\LaravelRestify\Commands\Refresh; use Binaryk\LaravelRestify\Commands\RepositoryCommand; use Binaryk\LaravelRestify\Commands\SetupCommand; +use Binaryk\LaravelRestify\Commands\StoreCommand; use Binaryk\LaravelRestify\Commands\StubCommand; use Binaryk\LaravelRestify\Http\Middleware\RestifyInjector; use Binaryk\LaravelRestify\Repositories\Repository; @@ -64,6 +65,7 @@ public function register() RepositoryCommand::class, ActionCommand::class, MatcherCommand::class, + StoreCommand::class, FilterCommand::class, DevCommand::class, ]); diff --git a/src/Repositories/DeleteField.php b/src/Repositories/DeleteField.php new file mode 100644 index 000000000..c879b6a16 --- /dev/null +++ b/src/Repositories/DeleteField.php @@ -0,0 +1,49 @@ +getStorageDisk(), $field->getStoragePath()); + } + + /** + * @var Deletable|Field $field + */ + if (! is_callable($callback = $field->getDeleteCallback())) { + return $model; + } + + $result = call_user_func_array($callback, $arguments); + + if ($result === true) { + return $model; + } + + if (! is_array($result)) { + $model->{$field->getAttribute()} = $result; + } else { + foreach ($result as $key => $value) { + if ($model->isFillable($key)) { + $model->{$key} = $value; + } + } + } + + return $model; + } +} diff --git a/src/Repositories/Storable.php b/src/Repositories/Storable.php new file mode 100644 index 000000000..2dd311a07 --- /dev/null +++ b/src/Repositories/Storable.php @@ -0,0 +1,11 @@ + 'Eduard', ]); } + + public function test_can_upload_avatar() + { + Storage::fake('customDisk'); + + $mock = UserRepository::partialMock() + ->shouldReceive('canUseForProfileUpdate') + ->andReturnTrue() + ->shouldReceive('fields') + ->andReturn([ + field('name'), + field('avatar_size'), + field('avatar_original'), + + Image::make('avatar') + ->rules('required') + ->disk('customDisk') + ->storeOriginalName('avatar_original') + ->storeSize('avatar_size') + ->storeAs('avatar.jpg'), + ]); + + $this->post('profile', [ + 'avatar' => UploadedFile::fake()->image('image.jpg'), + ])->assertOk()->assertJsonFragment([ + 'avatar_original' => 'image.jpg', + 'avatar' => '/storage/avatar.jpg', + ]); + + Storage::disk('customDisk')->assertExists('avatar.jpg'); + } } diff --git a/tests/Fields/FileTest.php b/tests/Fields/FileTest.php new file mode 100644 index 000000000..2f8724814 --- /dev/null +++ b/tests/Fields/FileTest.php @@ -0,0 +1,219 @@ +storeAs(function () { + return 'avatar.jpg'; + }); + + $request = RestifyRequest::create('/', 'GET', [], [], [ + 'avatar' => UploadedFile::fake()->image('image.jpg'), + ]); + + $field->fillAttribute($request, $model); + + $this->assertEquals('avatar.jpg', $model->avatar); + + Storage::disk('public')->assertExists('avatar.jpg'); + } + + public function test_can_upload_file() + { + Storage::fake('customDisk'); + + UserRepository::partialMock() + ->shouldReceive('fields') + ->andReturn([ + field('name'), + field('avatar_size'), + field('avatar_original'), + + Image::make('avatar') + ->rules('required') + ->disk('customDisk') + ->storeOriginalName('avatar_original') + ->storeSize('avatar_size') + ->storeAs('avatar.jpg'), + ]); + + $user = $this->mockUsers()->first(); + + $this->post(UserRepository::uriKey()."/{$user->id}", [ + 'avatar' => UploadedFile::fake()->image('image.jpg'), + ])->assertOk()->assertJsonFragment([ + 'avatar_original' => 'image.jpg', + 'avatar' => '/storage/avatar.jpg', + ]); + + Storage::disk('customDisk')->assertExists('avatar.jpg'); + } + + public function test_can_prune_prunable_files() + { + Storage::fake('customDisk'); + + $user = tap($this->mockUsers()->first(), function (User $user) { + $user->avatar = ($file = UploadedFile::fake()->image('image.jpg'))->storeAs('/', 'avatar.jpg', 'customDisk'); + $user->avatar_size = $file->getSize(); + $user->avatar_original = $file->getClientOriginalName(); + $user->save(); + }); + + $this->assertNotNull($user->avatar); + $this->assertNotNull($user->avatar_size); + $this->assertNotNull($user->avatar_original); + + Storage::disk('customDisk')->assertExists('avatar.jpg'); + + UserRepository::partialMock() + ->shouldReceive('fields') + ->andReturn([ + Image::make('avatar') + ->disk('customDisk') + ->prunable() + ->storeAs('avatar.jpg'), + ]); + + $this->delete(UserRepository::uriKey()."/{$user->id}") + ->dump() + ->assertNoContent(); + + Storage::disk('customDisk')->assertMissing('avatar.jpg'); + } + + public function test_cannot_prune_unpruneable_files() + { + Storage::fake('customDisk'); + + $user = tap($this->mockUsers()->first(), function (User $user) { + $user->avatar = ($file = UploadedFile::fake()->image('image.jpg'))->storeAs('/', 'avatar.jpg', 'customDisk'); + $user->save(); + }); + + Storage::disk('customDisk')->assertExists('avatar.jpg'); + + UserRepository::partialMock() + ->shouldReceive('fields') + ->andReturn([ + Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg'), + ]); + + $this->delete(UserRepository::uriKey()."/{$user->id}") + ->assertNoContent(); + + Storage::disk('customDisk')->assertExists('avatar.jpg'); + } + + public function test_deletable_file_could_be_deleted() + { + Storage::fake('customDisk'); + + $user = tap($this->mockUsers()->first(), function (User $user) { + $user->avatar = ($file = UploadedFile::fake()->image('image.jpg'))->storeAs('/', 'avatar.jpg', 'customDisk'); + $user->save(); + }); + + Storage::disk('customDisk')->assertExists('avatar.jpg'); + + UserRepository::partialMock() + ->shouldReceive('fields') + ->andReturn([ + Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg')->deletable(true), + ]); + + $this->delete(UserRepository::uriKey()."/{$user->id}/field/avatar") + ->assertNoContent(); + + Storage::disk('customDisk')->assertMissing('avatar.jpg'); + } + + public function test_not_deletable_file_cannot_be_deleted() + { + Storage::fake('customDisk'); + + $user = tap($this->mockUsers()->first(), function (User $user) { + $user->avatar = ($file = UploadedFile::fake()->image('image.jpg'))->storeAs('/', 'avatar.jpg', 'customDisk'); + $user->save(); + }); + + Storage::disk('customDisk')->assertExists('avatar.jpg'); + + UserRepository::partialMock() + ->shouldReceive('fields') + ->andReturn([ + Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg')->deletable(false), + ]); + + $this->delete(UserRepository::uriKey()."/{$user->id}/field/avatar") + ->assertNotFound(); + } + + public function test_can_upload_file_using_storable() + { + Storage::fake('customDisk'); + + UserRepository::partialMock() + ->shouldReceive('fields') + ->andReturn([ + Image::make('avatar')->store(AvatarStore::class), + ]); + + $user = $this->mockUsers()->first(); + + $this->post(UserRepository::uriKey()."/{$user->id}", [ + 'avatar' => UploadedFile::fake()->image('image.jpg'), + ])->assertOk()->assertJsonFragment([ + 'avatar' => '/storage/avatar.jpg', + ]); + + Storage::disk('customDisk')->assertExists('avatar.jpg'); + } + + public function test_model_updating_will_replace_file() + { + Storage::fake('customDisk'); + + $user = tap($this->mockUsers()->first(), function (User $user) { + $user->avatar = ($file = UploadedFile::fake()->image('image.jpg'))->storeAs('/', 'avatar.jpg', 'customDisk'); + $user->avatar_size = $file->getSize(); + $user->avatar_original = $file->getClientOriginalName(); + $user->save(); + }); + + Storage::disk('customDisk')->assertExists('avatar.jpg'); + + UserRepository::partialMock() + ->shouldReceive('fields') + ->andReturn([ + Image::make('avatar')->disk('customDisk')->storeAs('newAvatar.jpg')->prunable(), + ]); + + $this->post(UserRepository::uriKey()."/{$user->id}", [ + 'avatar' => UploadedFile::fake()->image('image.jpg'), + ])->assertOk()->assertJsonFragment([ + 'avatar' => '/storage/newAvatar.jpg', + ]); + + Storage::disk('customDisk')->assertMissing('avatar.jpg'); + Storage::disk('customDisk')->assertExists('newAvatar.jpg'); + } +} diff --git a/tests/Fixtures/User/AvatarFile.php b/tests/Fixtures/User/AvatarFile.php new file mode 100644 index 000000000..2cd2c6f10 --- /dev/null +++ b/tests/Fixtures/User/AvatarFile.php @@ -0,0 +1,9 @@ + $request->file('avatar')->storeAs('/', 'avatar.jpg', 'customDisk'), + ]; + } +} diff --git a/tests/Fixtures/User/User.php b/tests/Fixtures/User/User.php index 6e5a97c6c..dc7084621 100644 --- a/tests/Fixtures/User/User.php +++ b/tests/Fixtures/User/User.php @@ -15,7 +15,9 @@ use Mockery; /** - * @author Eduard Lupacescu + * @property string avatar + * @property string avatar_size + * @property string avatar_original */ class User extends Authenticatable implements Sanctumable, MustVerifyEmail, RestifySearchable { @@ -38,6 +40,8 @@ class User extends Authenticatable implements Sanctumable, MustVerifyEmail, Rest */ protected $fillable = [ 'name', 'email', 'password', 'email_verified_at', 'avatar', 'created_at', + 'avatar_size', + 'avatar_original', ]; /** 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 f03ee70b6..0630c4b3a 100644 --- a/tests/Migrations/2017_10_10_000000_create_users_table.php +++ b/tests/Migrations/2017_10_10_000000_create_users_table.php @@ -17,6 +17,8 @@ public function up() $table->increments('id'); $table->string('name'); $table->string('avatar')->nullable(); + $table->string('avatar_original')->nullable(); + $table->float('avatar_size')->nullable(); $table->string('email')->unique(); $table->string('password'); $table->timestamp('email_verified_at')->nullable();