From a8dc5afcb77e583cc320fd060c201b50adabfcb2 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Thu, 24 Dec 2020 16:39:12 +0200 Subject: [PATCH 1/5] File and Image fields. --- docs/docs/4.0/auth/profile.md | 94 +++++-- docs/docs/4.0/repository-pattern/field.md | 155 ++++++++++- routes/api.php | 5 + src/Commands/StoreCommand.php | 65 +++++ src/Commands/stubs/store.stub | 17 ++ src/Contracts/Deletable.php | 53 ++++ src/Contracts/FileStorable.php | 27 ++ src/Fields/Concerns/AcceptsTypes.php | 26 ++ src/Fields/Concerns/Deletable.php | 94 +++++++ src/Fields/Concerns/FileStorable.php | 78 ++++++ src/Fields/Field.php | 7 +- src/Fields/FieldCollection.php | 11 + src/Fields/File.php | 245 ++++++++++++++++++ src/Fields/Image.php | 33 +++ .../Controllers/Concerns/DeletesFields.php | 24 ++ .../Controllers/FieldDestroyController.php | 39 +++ .../Controllers/ProfileAvatarController.php | 2 +- src/Http/Controllers/ProfileController.php | 3 +- .../Controllers/ProfileUpdateController.php | 3 +- .../RepositoryDestroyController.php | 7 +- src/Http/Requests/ProfileAvatarRequest.php | 15 +- src/LaravelRestifyServiceProvider.php | 2 + src/Repositories/DeleteField.php | 49 ++++ src/Repositories/Storable.php | 11 + tests/Controllers/ProfileControllerTest.php | 34 +++ tests/Fields/FileTest.php | 190 ++++++++++++++ tests/Fixtures/User/AvatarFile.php | 10 + tests/Fixtures/User/AvatarStore.php | 17 ++ tests/Fixtures/User/User.php | 6 +- .../2017_10_10_000000_create_users_table.php | 2 + 30 files changed, 1290 insertions(+), 34 deletions(-) create mode 100644 src/Commands/StoreCommand.php create mode 100644 src/Commands/stubs/store.stub create mode 100644 src/Contracts/Deletable.php create mode 100644 src/Contracts/FileStorable.php create mode 100644 src/Fields/Concerns/AcceptsTypes.php create mode 100644 src/Fields/Concerns/Deletable.php create mode 100644 src/Fields/Concerns/FileStorable.php create mode 100644 src/Fields/File.php create mode 100644 src/Fields/Image.php create mode 100644 src/Http/Controllers/Concerns/DeletesFields.php create mode 100644 src/Http/Controllers/FieldDestroyController.php create mode 100644 src/Repositories/DeleteField.php create mode 100644 src/Repositories/Storable.php create mode 100644 tests/Fields/FileTest.php create mode 100644 tests/Fixtures/User/AvatarFile.php create mode 100644 tests/Fixtures/User/AvatarStore.php 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..ee1ba1ddf 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..2b379d2b0 --- /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..ee7365d7c --- /dev/null +++ b/src/Fields/File.php @@ -0,0 +1,245 @@ +prepareStorageCallback(); + + $this->delete(function () { + if ($this->value) { + Storage::disk($this->getStorageDisk())->delete($this->value); + + 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; + } + + $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; + } + } + + if ($this->isPrunable()) { + return function () use ($model, $request) { + call_user_func( + $this->deleteCallback, + $request, + $model, + $this->getStorageDisk(), + $this->getStoragePath() + ); + }; + } + + 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..f51b31dc1 --- /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..2e824d6e2 --- /dev/null +++ b/src/Http/Controllers/FieldDestroyController.php @@ -0,0 +1,39 @@ +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..8e465a77c --- /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..9573711d8 --- /dev/null +++ b/tests/Fields/FileTest.php @@ -0,0 +1,190 @@ +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'); + } +} diff --git a/tests/Fixtures/User/AvatarFile.php b/tests/Fixtures/User/AvatarFile.php new file mode 100644 index 000000000..bf623960e --- /dev/null +++ b/tests/Fixtures/User/AvatarFile.php @@ -0,0 +1,10 @@ + $request->file('avatar')->storeAs('/', 'avatar.jpg', 'customDisk') + ]; + } +} diff --git a/tests/Fixtures/User/User.php b/tests/Fixtures/User/User.php index 6e5a97c6c..e023766fd 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(); From e4c02e943a95a5d4757202985df9b04ba4fcde3d Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Thu, 24 Dec 2020 16:39:31 +0200 Subject: [PATCH 2/5] Apply fixes from StyleCI (#335) --- routes/api.php | 2 +- src/Fields/File.php | 17 +++++++------ .../Controllers/Concerns/DeletesFields.php | 4 ++-- .../Controllers/FieldDestroyController.php | 3 +-- src/Repositories/DeleteField.php | 2 +- tests/Controllers/ProfileControllerTest.php | 3 +-- tests/Fields/FileTest.php | 24 +++++++++---------- tests/Fixtures/User/AvatarFile.php | 1 - tests/Fixtures/User/AvatarStore.php | 2 +- tests/Fixtures/User/User.php | 2 +- 10 files changed, 28 insertions(+), 32 deletions(-) diff --git a/routes/api.php b/routes/api.php index ee1ba1ddf..5dad31bdf 100644 --- a/routes/api.php +++ b/routes/api.php @@ -52,7 +52,7 @@ Route::delete('/{repository}/{repositoryId}', '\\'.RepositoryDestroyController::class); // Fields -Route::delete('/{repository}/{repositoryId}/field/{field}', '\\' . FieldDestroyController::class); +Route::delete('/{repository}/{repositoryId}/field/{field}', '\\'.FieldDestroyController::class); // Attach related repository id Route::post('/{repository}/{repositoryId}/attach/{relatedRepository}', '\\'.RepositoryAttachController::class); diff --git a/src/Fields/File.php b/src/Fields/File.php index ee7365d7c..794ef9be7 100644 --- a/src/Fields/File.php +++ b/src/Fields/File.php @@ -2,8 +2,8 @@ namespace Binaryk\LaravelRestify\Fields; -use Binaryk\LaravelRestify\Contracts\FileStorable as StorableContract; use Binaryk\LaravelRestify\Contracts\Deletable as DeletableContract; +use Binaryk\LaravelRestify\Contracts\FileStorable as StorableContract; use Binaryk\LaravelRestify\Fields\Concerns\AcceptsTypes; use Binaryk\LaravelRestify\Fields\Concerns\Deletable; use Binaryk\LaravelRestify\Fields\Concerns\FileStorable; @@ -82,10 +82,10 @@ public function storeAs($storeAs): self protected function prepareStorageCallback(callable $storageCallback = null): void { $this->storageCallback = $storageCallback ?? function ($request, $model) { - return $this->mergeExtraStorageColumns($request, [ - $this->attribute => $this->storeFile($request, $this->attribute), - ]); - }; + return $this->mergeExtraStorageColumns($request, [ + $this->attribute => $this->storeFile($request, $this->attribute), + ]); + }; } /** @@ -116,7 +116,7 @@ public function storeSize($column) protected function storeFile(Request $request, string $requestAttribute) { - if (!$this->storeAs) { + if (! $this->storeAs) { return $request->file($requestAttribute)->store($this->getStorageDir(), $this->getStorageDisk()); } @@ -142,7 +142,6 @@ public function store($storageCallback): self return $this; } - /** * Merge the specified extra file information columns into the storable attributes. * @@ -187,7 +186,7 @@ protected function columnsThatShouldBeDeleted(): array public function fillAttribute(RestifyRequest $request, $model, int $bulkRow = null) { - if (is_null($file = $request->file($this->attribute)) || !$file->isValid()) { + if (is_null($file = $request->file($this->attribute)) || ! $file->isValid()) { return $this; } @@ -208,7 +207,7 @@ public function fillAttribute(RestifyRequest $request, $model, int $bulkRow = nu return $result; } - if (!is_array($result)) { + if (! is_array($result)) { return $model->{$attribute} = $result; } diff --git a/src/Http/Controllers/Concerns/DeletesFields.php b/src/Http/Controllers/Concerns/DeletesFields.php index f51b31dc1..ebe479971 100644 --- a/src/Http/Controllers/Concerns/DeletesFields.php +++ b/src/Http/Controllers/Concerns/DeletesFields.php @@ -14,8 +14,8 @@ protected function deleteFields(RestifyRequest $request, $model) ($repository = $request->newRepositoryWith($model)) ->collectFields($request) ->whereInstanceOf(Deletable::class) - ->filter(fn(Field $field) => $field instanceof Deletable) - ->filter(fn(Deletable $field) => $field->isPrunable()) + ->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 index 2e824d6e2..72522e1ff 100644 --- a/src/Http/Controllers/FieldDestroyController.php +++ b/src/Http/Controllers/FieldDestroyController.php @@ -3,7 +3,6 @@ namespace Binaryk\LaravelRestify\Http\Controllers; use Binaryk\LaravelRestify\Contracts\Deletable; -use Binaryk\LaravelRestify\Fields\Field; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\DeleteField; use Illuminate\Routing\Controller; @@ -20,7 +19,7 @@ public function __invoke(RestifyRequest $request) $field = $repository->collectFields($request) ->whereInstanceOf(Deletable::class) - ->filter(fn(Deletable $field) => $field->isDeletable()) + ->filter(fn (Deletable $field) => $field->isDeletable()) ->resolve($repository) ->findFieldByAttribute($request->field, function () { abort(404); diff --git a/src/Repositories/DeleteField.php b/src/Repositories/DeleteField.php index 8e465a77c..c879b6a16 100644 --- a/src/Repositories/DeleteField.php +++ b/src/Repositories/DeleteField.php @@ -34,7 +34,7 @@ public static function forRequest(RestifyRequest $request, $field, $model): Mode return $model; } - if (!is_array($result)) { + if (! is_array($result)) { $model->{$field->getAttribute()} = $result; } else { foreach ($result as $key => $value) { diff --git a/tests/Controllers/ProfileControllerTest.php b/tests/Controllers/ProfileControllerTest.php index 61794a83e..45c99217d 100644 --- a/tests/Controllers/ProfileControllerTest.php +++ b/tests/Controllers/ProfileControllerTest.php @@ -2,7 +2,6 @@ namespace Binaryk\LaravelRestify\Tests\Controllers; -use Binaryk\LaravelRestify\Fields\File; use Binaryk\LaravelRestify\Fields\Image; use Binaryk\LaravelRestify\Tests\Fixtures\User\User; use Binaryk\LaravelRestify\Tests\Fixtures\User\UserRepository; @@ -220,7 +219,7 @@ public function test_can_upload_avatar() ->disk('customDisk') ->storeOriginalName('avatar_original') ->storeSize('avatar_size') - ->storeAs('avatar.jpg') + ->storeAs('avatar.jpg'), ]); $this->post('profile', [ diff --git a/tests/Fields/FileTest.php b/tests/Fields/FileTest.php index 9573711d8..02dec9390 100644 --- a/tests/Fields/FileTest.php +++ b/tests/Fields/FileTest.php @@ -52,12 +52,12 @@ public function test_can_upload_file() ->disk('customDisk') ->storeOriginalName('avatar_original') ->storeSize('avatar_size') - ->storeAs('avatar.jpg') + ->storeAs('avatar.jpg'), ]); $user = $this->mockUsers()->first(); - $this->post(UserRepository::uriKey() . "/{$user->id}", [ + $this->post(UserRepository::uriKey()."/{$user->id}", [ 'avatar' => UploadedFile::fake()->image('image.jpg'), ])->assertOk()->assertJsonFragment([ 'avatar_original' => 'image.jpg', @@ -90,10 +90,10 @@ public function test_can_prune_prunable_files() Image::make('avatar') ->disk('customDisk') ->prunable() - ->storeAs('avatar.jpg') + ->storeAs('avatar.jpg'), ]); - $this->delete(UserRepository::uriKey() . "/{$user->id}") + $this->delete(UserRepository::uriKey()."/{$user->id}") ->dump() ->assertNoContent(); @@ -114,10 +114,10 @@ public function test_cannot_prune_unpruneable_files() UserRepository::partialMock() ->shouldReceive('fields') ->andReturn([ - Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg') + Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg'), ]); - $this->delete(UserRepository::uriKey() . "/{$user->id}") + $this->delete(UserRepository::uriKey()."/{$user->id}") ->assertNoContent(); Storage::disk('customDisk')->assertExists('avatar.jpg'); @@ -137,10 +137,10 @@ public function test_deletable_file_could_be_deleted() UserRepository::partialMock() ->shouldReceive('fields') ->andReturn([ - Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg')->deletable(true) + Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg')->deletable(true), ]); - $this->delete(UserRepository::uriKey() . "/{$user->id}/field/avatar") + $this->delete(UserRepository::uriKey()."/{$user->id}/field/avatar") ->assertNoContent(); Storage::disk('customDisk')->assertMissing('avatar.jpg'); @@ -160,10 +160,10 @@ public function test_not_deletable_file_cannot_be_deleted() UserRepository::partialMock() ->shouldReceive('fields') ->andReturn([ - Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg')->deletable(false) + Image::make('avatar')->disk('customDisk')->storeAs('avatar.jpg')->deletable(false), ]); - $this->delete(UserRepository::uriKey() . "/{$user->id}/field/avatar") + $this->delete(UserRepository::uriKey()."/{$user->id}/field/avatar") ->assertNotFound(); } @@ -174,12 +174,12 @@ public function test_can_upload_file_using_storable() UserRepository::partialMock() ->shouldReceive('fields') ->andReturn([ - Image::make('avatar')->store(AvatarStore::class) + Image::make('avatar')->store(AvatarStore::class), ]); $user = $this->mockUsers()->first(); - $this->post(UserRepository::uriKey() . "/{$user->id}", [ + $this->post(UserRepository::uriKey()."/{$user->id}", [ 'avatar' => UploadedFile::fake()->image('image.jpg'), ])->assertOk()->assertJsonFragment([ 'avatar' => '/storage/avatar.jpg', diff --git a/tests/Fixtures/User/AvatarFile.php b/tests/Fixtures/User/AvatarFile.php index bf623960e..2cd2c6f10 100644 --- a/tests/Fixtures/User/AvatarFile.php +++ b/tests/Fixtures/User/AvatarFile.php @@ -6,5 +6,4 @@ class AvatarFile extends File { - } diff --git a/tests/Fixtures/User/AvatarStore.php b/tests/Fixtures/User/AvatarStore.php index fa73d3bd7..9e085b8f9 100644 --- a/tests/Fixtures/User/AvatarStore.php +++ b/tests/Fixtures/User/AvatarStore.php @@ -11,7 +11,7 @@ class AvatarStore implements Storable public function handle(Request $request, Model $model, $attribute): array { return [ - 'avatar' => $request->file('avatar')->storeAs('/', 'avatar.jpg', 'customDisk') + 'avatar' => $request->file('avatar')->storeAs('/', 'avatar.jpg', 'customDisk'), ]; } } diff --git a/tests/Fixtures/User/User.php b/tests/Fixtures/User/User.php index e023766fd..dc7084621 100644 --- a/tests/Fixtures/User/User.php +++ b/tests/Fixtures/User/User.php @@ -41,7 +41,7 @@ class User extends Authenticatable implements Sanctumable, MustVerifyEmail, Rest protected $fillable = [ 'name', 'email', 'password', 'email_verified_at', 'avatar', 'created_at', 'avatar_size', - 'avatar_original' + 'avatar_original', ]; /** From 8876c5220b4a00366b1ef48c33f35d64b44d71f0 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Thu, 24 Dec 2020 17:57:29 +0200 Subject: [PATCH 3/5] wip --- src/Commands/stubs/store.stub | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Commands/stubs/store.stub b/src/Commands/stubs/store.stub index 2b379d2b0..79cd29800 100644 --- a/src/Commands/stubs/store.stub +++ b/src/Commands/stubs/store.stub @@ -6,7 +6,7 @@ use Binaryk\LaravelRestify\Repositories\Storable; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; -class DummyClass implements Matchable +class DummyClass implements Storable { public function handle(Request $request, Model $model, $attribute): array { From a7ffb8e7905fd999c431a3667a87f847429a1833 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Sat, 26 Dec 2020 12:08:28 +0200 Subject: [PATCH 4/5] Ensure prunable old file will be removed on update. --- src/Fields/File.php | 45 ++++++++++++++++++++------------------- tests/Fields/FileTest.php | 29 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/src/Fields/File.php b/src/Fields/File.php index 794ef9be7..414fbe870 100644 --- a/src/Fields/File.php +++ b/src/Fields/File.php @@ -51,9 +51,9 @@ public function __construct($attribute, callable $resolveCallback = null) $this->prepareStorageCallback(); - $this->delete(function () { - if ($this->value) { - Storage::disk($this->getStorageDisk())->delete($this->value); + $this->delete(function ($request, $model, $disk, $path) { + if ($file = $this->value ?? $model->{$this->attribute}) { + Storage::disk($this->getStorageDisk())->delete($file); return $this->columnsThatShouldBeDeleted(); } @@ -82,10 +82,10 @@ public function storeAs($storeAs): self protected function prepareStorageCallback(callable $storageCallback = null): void { $this->storageCallback = $storageCallback ?? function ($request, $model) { - return $this->mergeExtraStorageColumns($request, [ - $this->attribute => $this->storeFile($request, $this->attribute), - ]); - }; + return $this->mergeExtraStorageColumns($request, [ + $this->attribute => $this->storeFile($request, $this->attribute), + ]); + }; } /** @@ -116,7 +116,7 @@ public function storeSize($column) protected function storeFile(Request $request, string $requestAttribute) { - if (! $this->storeAs) { + if (!$this->storeAs) { return $request->file($requestAttribute)->store($this->getStorageDir(), $this->getStorageDisk()); } @@ -186,10 +186,23 @@ protected function columnsThatShouldBeDeleted(): array public function fillAttribute(RestifyRequest $request, $model, int $bulkRow = null) { - if (is_null($file = $request->file($this->attribute)) || ! $file->isValid()) { + 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, @@ -207,7 +220,7 @@ public function fillAttribute(RestifyRequest $request, $model, int $bulkRow = nu return $result; } - if (! is_array($result)) { + if (!is_array($result)) { return $model->{$attribute} = $result; } @@ -217,18 +230,6 @@ public function fillAttribute(RestifyRequest $request, $model, int $bulkRow = nu } } - if ($this->isPrunable()) { - return function () use ($model, $request) { - call_user_func( - $this->deleteCallback, - $request, - $model, - $this->getStorageDisk(), - $this->getStoragePath() - ); - }; - } - return $this; } diff --git a/tests/Fields/FileTest.php b/tests/Fields/FileTest.php index 02dec9390..c488ddaed 100644 --- a/tests/Fields/FileTest.php +++ b/tests/Fields/FileTest.php @@ -187,4 +187,33 @@ public function test_can_upload_file_using_storable() 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'); + } } From 92a7af4c3e6a325a4dd354132f1ed857086d809a Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Sat, 26 Dec 2020 12:08:50 +0200 Subject: [PATCH 5/5] Apply fixes from StyleCI (#338) --- src/Fields/File.php | 14 +++++++------- tests/Fields/FileTest.php | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Fields/File.php b/src/Fields/File.php index 414fbe870..94dbc2010 100644 --- a/src/Fields/File.php +++ b/src/Fields/File.php @@ -82,10 +82,10 @@ public function storeAs($storeAs): self protected function prepareStorageCallback(callable $storageCallback = null): void { $this->storageCallback = $storageCallback ?? function ($request, $model) { - return $this->mergeExtraStorageColumns($request, [ - $this->attribute => $this->storeFile($request, $this->attribute), - ]); - }; + return $this->mergeExtraStorageColumns($request, [ + $this->attribute => $this->storeFile($request, $this->attribute), + ]); + }; } /** @@ -116,7 +116,7 @@ public function storeSize($column) protected function storeFile(Request $request, string $requestAttribute) { - if (!$this->storeAs) { + if (! $this->storeAs) { return $request->file($requestAttribute)->store($this->getStorageDir(), $this->getStorageDisk()); } @@ -186,7 +186,7 @@ protected function columnsThatShouldBeDeleted(): array public function fillAttribute(RestifyRequest $request, $model, int $bulkRow = null) { - if (is_null($file = $request->file($this->attribute)) || !$file->isValid()) { + if (is_null($file = $request->file($this->attribute)) || ! $file->isValid()) { return $this; } @@ -220,7 +220,7 @@ public function fillAttribute(RestifyRequest $request, $model, int $bulkRow = nu return $result; } - if (!is_array($result)) { + if (! is_array($result)) { return $model->{$attribute} = $result; } diff --git a/tests/Fields/FileTest.php b/tests/Fields/FileTest.php index c488ddaed..2f8724814 100644 --- a/tests/Fields/FileTest.php +++ b/tests/Fields/FileTest.php @@ -204,7 +204,7 @@ public function test_model_updating_will_replace_file() UserRepository::partialMock() ->shouldReceive('fields') ->andReturn([ - Image::make('avatar')->disk('customDisk')->storeAs('newAvatar.jpg')->prunable() + Image::make('avatar')->disk('customDisk')->storeAs('newAvatar.jpg')->prunable(), ]); $this->post(UserRepository::uriKey()."/{$user->id}", [