Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fill props from route parameters #341

Merged
merged 22 commits into from
Feb 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ea920c9
Introduce FromRouteModel attribute
ragulka Dec 22, 2022
ab50842
Introduce FillRouteModelPropertiesDataPipe
ragulka Dec 22, 2022
4ad29be
include FillRouteModelPropertiesDataPipe in BaseData pipeline
ragulka Dec 22, 2022
a43c903
update renamed property usage
ragulka Dec 22, 2022
daab7f5
add tests for FillRouteModelPropertiesDataPipe
ragulka Dec 22, 2022
98a99a6
rename params
ragulka Jan 6, 2023
3d7d3fc
support nested route model properties
ragulka Jan 6, 2023
12f8e46
Use data_get instead of converting model to array
ragulka Jan 31, 2023
db6ed84
remove model instance check
ragulka Jan 31, 2023
6517d36
rename attribute to FromRouteParameter
ragulka Jan 31, 2023
e810b20
rename test file
ragulka Jan 31, 2023
62e153e
update test descriptions
ragulka Jan 31, 2023
aa4b0c3
rename model variable to parameter
ragulka Jan 31, 2023
21b3656
update tests to ensure getting properties from arrays is also supported
ragulka Jan 31, 2023
3b82199
support filling properties with full route parameters (not just prope…
ragulka Jan 31, 2023
bb96320
add test case showcasing scalar route parameter property handling
ragulka Jan 31, 2023
970bb59
break into 2 attributes
ragulka Feb 1, 2023
9023324
refactor pipeline to use 2 different attributes, throw exception when…
ragulka Feb 1, 2023
21d85a4
update tests
ragulka Feb 1, 2023
a6032af
uncomment exception assertion
ragulka Feb 1, 2023
e918b2c
Merge branch 'main' into fill-props-from-route-models
ragulka Feb 1, 2023
b87c4ca
add docs
ragulka Feb 1, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions docs/advanced-usage/filling from-route-parameters.md
Original file line number Diff line number Diff line change
@@ -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
}
}
```
15 changes: 15 additions & 0 deletions src/Attributes/FromRouteParameter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Spatie\LaravelData\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
class FromRouteParameter
{
public function __construct(
public string $routeParameter,
public bool $replaceWhenPresentInBody = true,
) {
}
}
16 changes: 16 additions & 0 deletions src/Attributes/FromRouteParameterProperty.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Spatie\LaravelData\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
class FromRouteParameterProperty
{
public function __construct(
public string $routeParameter,
public ?string $property = null,
public bool $replaceWhenPresentInBody = true,
) {
}
}
2 changes: 2 additions & 0 deletions src/Concerns/BaseData.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Spatie\LaravelData\DataPipes\AuthorizedDataPipe;
use Spatie\LaravelData\DataPipes\CastPropertiesDataPipe;
use Spatie\LaravelData\DataPipes\DefaultValuesDataPipe;
use Spatie\LaravelData\DataPipes\FillRouteParameterPropertiesDataPipe;
use Spatie\LaravelData\DataPipes\MapPropertiesDataPipe;
use Spatie\LaravelData\DataPipes\ValidatePropertiesDataPipe;
use Spatie\LaravelData\PaginatedDataCollection;
Expand Down Expand Up @@ -73,6 +74,7 @@ public static function pipeline(): DataPipeline
->into(static::class)
->through(AuthorizedDataPipe::class)
->through(MapPropertiesDataPipe::class)
->through(FillRouteParameterPropertiesDataPipe::class)
->through(ValidatePropertiesDataPipe::class)
->through(DefaultValuesDataPipe::class)
->through(CastPropertiesDataPipe::class);
Expand Down
48 changes: 48 additions & 0 deletions src/DataPipes/FillRouteParameterPropertiesDataPipe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace Spatie\LaravelData\DataPipes;

use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Spatie\LaravelData\Attributes\FromRouteParameter;
use Spatie\LaravelData\Attributes\FromRouteParameterProperty;
use Spatie\LaravelData\Exceptions\CannotFillFromRouteParameterPropertyUsingScalarValue;
use Spatie\LaravelData\Support\DataClass;

class FillRouteParameterPropertiesDataPipe implements DataPipe
{
public function handle(mixed $payload, DataClass $class, Collection $properties): Collection
{
if (! $payload instanceof Request) {
return $properties;
}

foreach ($class->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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Spatie\LaravelData\Exceptions;

use Exception;
use Spatie\LaravelData\Attributes\FromRouteParameterProperty;
use Spatie\LaravelData\Support\DataProperty;

class CannotFillFromRouteParameterPropertyUsingScalarValue extends Exception
{
public static function create(DataProperty $property, FromRouteParameterProperty $attribute, mixed $value): self
{
return new self("Attribute FromRouteParameterProperty cannot be used with scalar route parameters. {$property->className}::{$property->name} is configured to be filled from {$attribute->routeParameter}::{$attribute->property}, but the route parameter has a scalar value ({$value}).");
}
}
Loading