Skip to content

Commit 7c562a5

Browse files
authored
fix(laravel): partial patch validation config to replace required with sometimes (#7882)
Fixes #7648
1 parent e447ab1 commit 7c562a5

9 files changed

Lines changed: 250 additions & 1 deletion

File tree

src/Laravel/ApiPlatformDeferredProvider.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,8 @@ public function register(): void
246246
$this->app->make(LoggerInterface::class)
247247
),
248248
$app->make('filters')
249-
)
249+
),
250+
(bool) $config->get('api-platform.partial_patch_validation', false)
250251
),
251252
true === $config->get('app.debug') ? 'array' : $config->get('api-platform.cache', 'file')
252253
);

src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ final class EloquentResourceCollectionMetadataFactory implements ResourceMetadat
5454

5555
public function __construct(
5656
private readonly ResourceMetadataCollectionFactoryInterface $decorated,
57+
private readonly bool $partialPatchValidation = false,
5758
) {
5859
}
5960

@@ -97,6 +98,13 @@ public function create(string $resourceClass): ResourceMetadataCollection
9798
$operation = $operation->withProcessor($operation instanceof DeleteOperationInterface ? RemoveProcessor::class : PersistProcessor::class);
9899
}
99100

101+
if ($this->partialPatchValidation && $operation instanceof Patch) {
102+
$rules = $operation->getRules();
103+
if (\is_array($rules)) {
104+
$operation = $operation->withRules($this->replaceRequiredWithSometimes($rules));
105+
}
106+
}
107+
100108
$operations->add($operationName, $operation);
101109
}
102110

@@ -130,4 +138,26 @@ public function create(string $resourceClass): ResourceMetadataCollection
130138

131139
return $resourceMetadataCollection;
132140
}
141+
142+
/**
143+
* Replaces 'required' with 'sometimes' in validation rules for partial updates.
144+
*
145+
* @param array<string, mixed> $rules
146+
*
147+
* @return array<string, mixed>
148+
*/
149+
private function replaceRequiredWithSometimes(array $rules): array
150+
{
151+
foreach ($rules as $field => $fieldRules) {
152+
if (\is_string($fieldRules)) {
153+
$parts = explode('|', $fieldRules);
154+
$parts = array_map(static fn (string $rule): string => 'required' === $rule ? 'sometimes' : $rule, $parts);
155+
$rules[$field] = implode('|', $parts);
156+
} elseif (\is_array($fieldRules)) {
157+
$rules[$field] = array_map(static fn (mixed $rule): mixed => 'required' === $rule ? 'sometimes' : $rule, $fieldRules);
158+
}
159+
}
160+
161+
return $rules;
162+
}
133163
}

src/Laravel/Tests/ValidationTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Illuminate\Foundation\Testing\RefreshDatabase;
2020
use Orchestra\Testbench\Concerns\WithWorkbench;
2121
use Orchestra\Testbench\TestCase;
22+
use Workbench\Database\Factories\Issue7648CommentFactory;
2223

2324
class ValidationTest extends TestCase
2425
{
@@ -34,6 +35,7 @@ protected function defineEnvironment($app): void
3435
tap($app['config'], static function (Repository $config): void {
3536
$config->set('api-platform.formats', ['jsonld' => ['application/ld+json']]);
3637
$config->set('api-platform.docs_formats', ['jsonld' => ['application/ld+json']]);
38+
$config->set('api-platform.partial_patch_validation', true);
3739
});
3840
}
3941

@@ -76,6 +78,29 @@ public function testRouteWithRequirements(): void
7678
$response->assertStatus(200);
7779
}
7880

81+
/**
82+
* @see https://github.com/api-platform/core/issues/7648
83+
*/
84+
public function testPatchDoesNotRequireRelationshipFields(): void
85+
{
86+
$comment = Issue7648CommentFactory::new()->create();
87+
$iri = $this->getIriFromResource($comment);
88+
89+
// PATCH with empty body should not fail on 'required' for relationship fields
90+
$response = $this->patchJson($iri, [], ['accept' => 'application/ld+json', 'content-type' => 'application/merge-patch+json']);
91+
$response->assertStatus(200);
92+
}
93+
94+
/**
95+
* @see https://github.com/api-platform/core/issues/7648
96+
*/
97+
public function testPostStillRequiresRelationshipFields(): void
98+
{
99+
$response = $this->postJson('/api/issue7648_comments', ['content' => 'test'], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']);
100+
$response->assertStatus(422);
101+
$response->assertJsonFragment(['propertyPath' => 'article', 'message' => 'The article field is required.']);
102+
}
103+
79104
public function testGetCollectionWithFormRequestValidation(): void
80105
{
81106
$response = $this->get('/api/slots/dropoff', ['accept' => 'application/ld+json']);

src/Laravel/config/api-platform.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@
4242
'json' => ['application/merge-patch+json'],
4343
],
4444

45+
// When true, 'required' validation rules are replaced with 'sometimes'
46+
// on PATCH operations, allowing partial updates without requiring all fields.
47+
'partial_patch_validation' => false,
48+
4549
'docs_formats' => [
4650
'jsonld' => ['application/ld+json'],
4751
// 'jsonapi' => ['application/vnd.api+json'],
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Workbench\App\Models;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use Illuminate\Database\Eloquent\Factories\HasFactory;
18+
use Illuminate\Database\Eloquent\Model;
19+
use Illuminate\Database\Eloquent\Relations\HasMany;
20+
21+
#[ApiResource(
22+
rules: [
23+
'title' => ['required', 'max:255'],
24+
'content' => ['required'],
25+
],
26+
)]
27+
class Issue7648Article extends Model
28+
{
29+
use HasFactory;
30+
31+
protected $table = 'issue7648_articles';
32+
33+
public function comments(): HasMany
34+
{
35+
return $this->hasMany(Issue7648Comment::class, 'article_id');
36+
}
37+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Workbench\App\Models;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use Illuminate\Database\Eloquent\Factories\HasFactory;
18+
use Illuminate\Database\Eloquent\Model;
19+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
20+
21+
#[ApiResource(
22+
rules: [
23+
'article' => ['required'],
24+
'content' => ['required'],
25+
],
26+
)]
27+
class Issue7648Comment extends Model
28+
{
29+
use HasFactory;
30+
31+
protected $table = 'issue7648_comments';
32+
33+
public function article(): BelongsTo
34+
{
35+
return $this->belongsTo(Issue7648Article::class, 'article_id');
36+
}
37+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Workbench\Database\Factories;
15+
16+
use Illuminate\Database\Eloquent\Factories\Factory;
17+
use Workbench\App\Models\Issue7648Article;
18+
19+
/**
20+
* @template TModel of Issue7648Article
21+
*
22+
* @extends Factory<TModel>
23+
*/
24+
class Issue7648ArticleFactory extends Factory
25+
{
26+
/** @var class-string<TModel> */
27+
protected $model = Issue7648Article::class;
28+
29+
/** @return array<string, mixed> */
30+
public function definition(): array
31+
{
32+
return [
33+
'title' => fake()->sentence(),
34+
'content' => fake()->paragraph(),
35+
];
36+
}
37+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Workbench\Database\Factories;
15+
16+
use Illuminate\Database\Eloquent\Factories\Factory;
17+
use Workbench\App\Models\Issue7648Comment;
18+
19+
/**
20+
* @template TModel of Issue7648Comment
21+
*
22+
* @extends Factory<TModel>
23+
*/
24+
class Issue7648CommentFactory extends Factory
25+
{
26+
/** @var class-string<TModel> */
27+
protected $model = Issue7648Comment::class;
28+
29+
/** @return array<string, mixed> */
30+
public function definition(): array
31+
{
32+
return [
33+
'article_id' => Issue7648ArticleFactory::new(),
34+
'content' => fake()->paragraph(),
35+
];
36+
}
37+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
use Illuminate\Database\Migrations\Migration;
15+
use Illuminate\Database\Schema\Blueprint;
16+
use Illuminate\Support\Facades\Schema;
17+
18+
return new class extends Migration {
19+
public function up(): void
20+
{
21+
Schema::create('issue7648_articles', static function (Blueprint $table): void {
22+
$table->id();
23+
$table->string('title');
24+
$table->text('content');
25+
$table->timestamps();
26+
});
27+
28+
Schema::create('issue7648_comments', static function (Blueprint $table): void {
29+
$table->id();
30+
$table->foreignId('article_id')->constrained('issue7648_articles');
31+
$table->text('content');
32+
$table->timestamps();
33+
});
34+
}
35+
36+
public function down(): void
37+
{
38+
Schema::dropIfExists('issue7648_comments');
39+
Schema::dropIfExists('issue7648_articles');
40+
}
41+
};

0 commit comments

Comments
 (0)