diff --git a/docs/advanced-usage/filling from-route-parameters.md b/docs/advanced-usage/filling from-route-parameters.md new file mode 100644 index 00000000..65f24417 --- /dev/null +++ b/docs/advanced-usage/filling from-route-parameters.md @@ -0,0 +1,184 @@ +--- +title: Filling properties from route parameters +weight: 9 +--- + +When creating data objects from requests, it's possible to automatically fill data properties from request route parameters, such as route models. + +## Filling properties from a route parameter + +The `FromRouteParameter` attribute allows filling properties with route parameter values. + +### Using simple (scalar) route parameters + +```php +Route::patch('/songs/{songId}', [SongController::class, 'update']); + +class SongData extends Data { + #[FromRouteParameter('songId')] + public int $id; + public string $name; +} +``` +Here, the `$id` property will be filled with the `songId` route parameter value (which most likely is a string or integer). +### Using Models, objects or arrays as route parameters** + + +Given that we have a route to create songs for a specific author, and that the `{author}` route parameter uses route model binding to automatically bind to an `Author` model: + +```php +Route::post('/songs/{author}', [SongController::class, 'store']); + +class SongData extends Data { + public int $id; + #[FromRouteParameter('author')] + public AuthorData $author; +} +``` +Here, the `$author` property will be filled with the `author` route parameter value, which will be an instance of the `Author` model. Note that Laravel Data will automatically cast the model to `AuthorData`. + +## Filling properties from route parameter properties + +The `FromRouteParameterProperty` attribute allows filling properties with values from route parameter properties. The main difference from `FromRouteParameter` is that the former uses the full route parameter value, while `FromRouteParameterProperty` uses a single property from the route parameter. + +In the example below we're using route model binding. `{song}` represents an instance of the `Song` model. `FromRouteParameterProperty` automatically attempts to fill the `SongData` `$id` property from `$song->id`. + +```php +Route::patch('/songs/{song}', [SongController::class, 'update']); + +class SongData extends Data { + #[FromRouteParameterProperty('song')] + public int $id; + public string $name; +} +``` +### Using custom property mapping + +In the example below, `$name` property will be filled with `$song->title` (instead of `$song->name). + +```php +Route::patch('/songs/{song}', [SongController::class, 'update']); + +class SongData extends Data { + #[FromRouteParameterProperty('song')] + public int $id; + #[FromRouteParameterProperty('song', 'title')] + public string $name; +} +``` + +### Nested property mapping + +Nested properties ar supported as well. Here, we fill `$singerName` from `$artist->leadSinger->name`: + +```php +Route::patch('/artists/{artist}/songs/{song}', [SongController::class, 'update']); + +class SongData extends Data { + #[FromRouteParameterProperty('song')] + public int $id; + #[FromRouteParameterProperty('artist', 'leadSinger.name')] + public string $singerName; +} +``` + +### Other supported route parameter types + +`FromRouteParameterProperty` is compatible with anything that Laravel's [`data_get()` helper](https://laravel.com/docs/9.x/helpers#method-data-get) supports. This includes most objects and arrays: + +```php +// $song = ['foo' => ['bar' => ['baz' => ['qux' => 'Foonderbar!'] ] ] ]; + +class SongData extends Data { + #[FromRouteParameterProperty('song', 'bar.baz.qux')] + public int $title; +} +``` + +## Route parameters take priority over request body + +By default, route parameters take priority over values in the request body. For example, when the song ID is present in the route model as well as request body, the ID from route model is used. + +```php +Route::patch('/songs/{song}', [SongController::class, 'update']); + +// PATCH /songs/123 +// { "id": 321, "name": "Never gonna give you up" } + +class SongData extends Data { + #[FromRouteParameterProperty('song')] + public int $id; + public string $name; +} +``` +Here, `$id` will be `123` even though the request body has `321` as the ID value. + +In most cases, this is useful - especially when you need the ID for a validation rule. However, there may be cases when the exact opposite is required. + +The above behavior can be turned off by switching the `replaceWhenPresentInBody` flag off. This can be useful when you _intend_ to allow updating a property that is present in a route parameter, such as a slug: + +```php +Route::patch('/songs/{slug}', [SongController::class, 'update']); + +// PATCH /songs/never +// { "slug": "never-gonna-give-you-up", "name": "Never gonna give you up" } + +class SongData extends Data { + #[FromRouteParamete('slug', replaceWhenPresentInBody: false )] + public string $slug; +} +``` + +Here, `$slug` will be `never-gonna-give-you-up` even though the route parameter value is `never`. + +## Using in combination with validation rules + +Filling properties from route parameters can be incredibly useful when dealing with validation rules. Some validation rules may depend on a model property that may not be present in the request body. + +### Example using the Unique validation rule + +Using [Laravel's unique](https://laravel.com/docs/9.x/validation#rule-unique) validation rule, it may be necessary to have the rule ignore a given ID - this is usually the ID of the model being updated + +**The Data class** + +```php +use \Spatie\LaravelData\Attributes\FromRouteParameterProperty; + +class SongData extends Data +{ + public function __construct( + #[FromRouteParameterProperty('song')] + public ?int $id, + public ?string $external_id, + public ?string $title, + public ?string $artist, + ) { + } + + public static function rules(array $payload) : array + { + return [ + // Here, $payload['id'] is already filled from the route model, so the following + // unique rule works as expected - it ignores the current model when validating + // uniqueness of external_id + 'external_id' => [Rule::unique('songs')->ignore(Arr::get($payload, 'id'))], + ]; + } +} +``` +**Route & Controller** + +```php +Route::patch('/songs/{song}', [SongController, 'update']); + +// PATCH /songs/123 +// { "external_id": "remote_id_321", "name": "Never gonna give you up" } + +class SongController extends Controller { + + public function update(Song $song, SongData $data) + { + // here, $data is already validated + } +} +``` diff --git a/src/Attributes/FromRouteParameter.php b/src/Attributes/FromRouteParameter.php new file mode 100644 index 00000000..d11e44c6 --- /dev/null +++ b/src/Attributes/FromRouteParameter.php @@ -0,0 +1,15 @@ +into(static::class) ->through(AuthorizedDataPipe::class) ->through(MapPropertiesDataPipe::class) + ->through(FillRouteParameterPropertiesDataPipe::class) ->through(ValidatePropertiesDataPipe::class) ->through(DefaultValuesDataPipe::class) ->through(CastPropertiesDataPipe::class); diff --git a/src/DataPipes/FillRouteParameterPropertiesDataPipe.php b/src/DataPipes/FillRouteParameterPropertiesDataPipe.php new file mode 100644 index 00000000..e9a543db --- /dev/null +++ b/src/DataPipes/FillRouteParameterPropertiesDataPipe.php @@ -0,0 +1,48 @@ +properties as $dataProperty) { + if (! ($attribute = $dataProperty->attributes->first(fn ($attribute) => $attribute instanceof FromRouteParameter || $attribute instanceof FromRouteParameterProperty))) { + continue; + } + + if (! $attribute->replaceWhenPresentInBody && $properties->has($dataProperty->name)) { + continue; + } + + if (! ($parameter = $payload->route($attribute->routeParameter))) { + continue; + } + + if ($attribute instanceof FromRouteParameterProperty) { + if (is_scalar($parameter)) { + throw CannotFillFromRouteParameterPropertyUsingScalarValue::create($dataProperty, $attribute, $parameter); + } + + $value = data_get($parameter, $attribute->property ?? $dataProperty->name); + } else { + $value = $parameter; + } + + $properties->put($dataProperty->name, $value); + } + + return $properties; + } +} diff --git a/src/Exceptions/CannotFillFromRouteParameterPropertyUsingScalarValue.php b/src/Exceptions/CannotFillFromRouteParameterPropertyUsingScalarValue.php new file mode 100644 index 00000000..228028ae --- /dev/null +++ b/src/Exceptions/CannotFillFromRouteParameterPropertyUsingScalarValue.php @@ -0,0 +1,15 @@ +className}::{$property->name} is configured to be filled from {$attribute->routeParameter}::{$attribute->property}, but the route parameter has a scalar value ({$value})."); + } +} diff --git a/tests/DataPipes/FillRouteParameterPropertiesPipeTest.php b/tests/DataPipes/FillRouteParameterPropertiesPipeTest.php new file mode 100644 index 00000000..9e91b59d --- /dev/null +++ b/tests/DataPipes/FillRouteParameterPropertiesPipeTest.php @@ -0,0 +1,184 @@ +expects('route')->with('id')->once()->andReturns(123); + $requestMock->expects('route')->with('slug')->once()->andReturns('foo-bar'); + $requestMock->expects('route')->with('title')->once()->andReturns('Foo Bar'); + $requestMock->expects('route')->with('tags')->once()->andReturns(['foo', 'bar']); + $requestMock->expects('route')->with('nested')->once()->andReturns(['simple' => ['string' => 'baz']]); + $requestMock->expects('toArray')->andReturns([]); + + $data = $dataClass::from($requestMock); + + expect($data->id)->toEqual(123); + expect($data->slug)->toEqual('foo-bar'); + expect($data->title)->toEqual('Foo Bar'); + expect($data->tags)->toEqual(['foo', 'bar']); + expect($data->nested->simple->string)->toEqual('baz'); +}); + +it('can fill data properties from route parameter properties', function () { + $dataClass = new class () extends Data { + #[FromRouteParameterProperty('foo')] + public int $id; + #[FromRouteParameterProperty('bar')] + public string $name; + #[FromRouteParameterProperty('baz')] + public string $description; + }; + + $foo = new class () extends Model { + protected $attributes = [ + 'id' => 123, + ]; + }; + + $bar = ['name' => 'Baz']; + + $baz = (object) ['description' => 'The bazzest bazz there is']; + + $requestMock = mock(Request::class); + $requestMock->expects('route')->with('foo')->once()->andReturns($foo); + $requestMock->expects('route')->with('bar')->once()->andReturns($bar); + $requestMock->expects('route')->with('baz')->once()->andReturns($baz); + $requestMock->expects('toArray')->andReturns([]); + + $data = $dataClass::from($requestMock); + + expect($data->id)->toEqual(123); + expect($data->name)->toEqual('Baz'); + expect($data->description)->toEqual('The bazzest bazz there is'); +}); + +it('can fill data properties from route parameters using custom property mapping ', function () { + $dataClass = new class () extends Data { + #[FromRouteParameterProperty('something', 'name')] + public string $title; + #[FromRouteParameterProperty('something', 'nested.foo')] + public string $foo; + #[FromRouteParameterProperty('something', 'tags.0')] + public string $tag; + #[FromRouteParameterProperty('something', 'rows.*.total')] + public array $totals; + }; + + $something = [ + 'name' => 'Something', + 'nested' => [ + 'foo' => 'bar', + ], + 'tags' => ['foo', 'bar'], + 'rows' => [ + ['total' => 10], + ['total' => 20], + ['total' => 30], + ], + ]; + + $requestMock = mock(Request::class); + $requestMock->expects('route')->with('something')->times(4)->andReturns($something); + $requestMock->expects('toArray')->andReturns([]); + + $data = $dataClass::from($requestMock); + + expect($data->title)->toEqual('Something'); + expect($data->foo)->toEqual('bar'); + expect($data->tag)->toEqual('foo'); + expect($data->totals)->toEqual([10, 20, 30]); +}); + +it('replaces properties when route parameter properties exist', function () { + $dataClass = new class () extends Data { + #[FromRouteParameter('foo')] + public string $name; + #[FromRouteParameterProperty('bar')] + public string $slug; + }; + + $foo = 'Foo Lighters'; + $bar = ['slug' => 'foo-lighters']; + + $requestMock = mock(Request::class); + $requestMock->expects('route')->with('foo')->once()->andReturns($foo); + $requestMock->expects('route')->with('bar')->once()->andReturns($bar); + $requestMock->expects('toArray')->andReturns(['name' => 'Loo Cleaners', 'slug' => 'loo-cleaners']); + + $data = $dataClass::from($requestMock); + + expect($data->name)->toEqual('Foo Lighters'); + expect($data->slug)->toEqual('foo-lighters'); +}); + +it('skips replacing properties when route parameter properties exist and replacing is disabled', function () { + $dataClass = new class () extends Data { + #[FromRouteParameter('foo', replaceWhenPresentInBody: false)] + public string $name; + #[FromRouteParameterProperty('bar', replaceWhenPresentInBody: false)] + public string $slug; + }; + + $requestMock = mock(Request::class); + $requestMock->expects('route')->with('foo')->never(); + $requestMock->expects('route')->with('bar')->never(); + $requestMock->expects('toArray')->andReturns(['name' => 'Loo Cleaners', 'slug' => 'loo-cleaners']); + + $data = $dataClass::from($requestMock); + + expect($data->name)->toEqual('Loo Cleaners'); + expect($data->slug)->toEqual('loo-cleaners'); +}); + +it('skips properties it cannot find a route parameter for', function () { + $dataClass = new class () extends Data { + #[FromRouteParameter('foo')] + public string $name; + #[FromRouteParameterProperty('bar')] + public ?string $slug = 'default-slug'; + }; + + $requestMock = mock(Request::class); + $requestMock->expects('route')->with('foo')->once()->andReturnNull(); + $requestMock->expects('route')->with('bar')->once()->andReturnNull(); + $requestMock->expects('toArray')->andReturns(['name' => 'Moo Makers']); + + $data = $dataClass::from($requestMock); + + expect($data->name)->toEqual('Moo Makers'); + expect($data->slug)->toEqual('default-slug'); +}); + +it('throws when trying to fill from a route parameter that has a scalar value', function () { + $dataClass = new class () extends Data { + #[FromRouteParameterProperty('foo', 'bar')] + public string $name; + }; + + $requestMock = mock(Request::class); + $requestMock->expects('route')->with('foo')->once()->andReturn('baz'); + $requestMock->expects('toArray')->andReturns([]); + + $dataClass::from($requestMock); +})->throws(CannotFillFromRouteParameterPropertyUsingScalarValue::class);