Skip to content

Commit 26bc937

Browse files
Standardize GraphQL mutation error handling (#3680)
Our GraphQL mutations currently use a combination of thrown and caught exceptions, lighthouse-php error handling, and a `messages` data field used as an error field. This PR switches everything to report proper GraphQL errors where appropriate, allowing clients to process errors using built-in error handling mechanisms.
1 parent 3e6cfd0 commit 26bc937

32 files changed

Lines changed: 281 additions & 588 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace App\Exceptions;
4+
5+
use Exception;
6+
use GraphQL\Error\ClientAware;
7+
8+
class GraphQLMutationException extends Exception implements ClientAware
9+
{
10+
public function isClientSafe(): bool
11+
{
12+
return true;
13+
}
14+
}

app/GraphQL/Mutations/ChangeGlobalRole.php

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
namespace App\GraphQL\Mutations;
66

77
use App\Enums\GlobalRole;
8+
use App\Exceptions\GraphQLMutationException;
89
use App\Models\User;
9-
use Exception;
1010
use Illuminate\Support\Facades\Validator;
1111
use Illuminate\Validation\Rule;
1212
use Illuminate\Validation\ValidationException;
@@ -21,9 +21,10 @@ final class ChangeGlobalRole extends AbstractMutation
2121
* role: GlobalRole,
2222
* } $args
2323
*
24+
* @throws GraphQLMutationException
2425
* @throws ValidationException
2526
*/
26-
protected function mutate(array $args): void
27+
public function __invoke(null $_, array $args): self
2728
{
2829
Validator::make($args, [
2930
'userId' => [
@@ -39,23 +40,23 @@ protected function mutate(array $args): void
3940
$user = auth()->user();
4041
if ($user === null) {
4142
// This should never happen, but we handle the case anyway to make PHPStan happy.
42-
throw new Exception('Attempt to invite user when not signed in.');
43+
throw new GraphQLMutationException('Attempt to invite user when not signed in.');
4344
}
4445

4546
$userToChange = User::find((int) $args['userId']);
4647
if ($userToChange === null) {
47-
$this->message = 'Cannot change role for user which does not exist.';
48-
return;
48+
throw new GraphQLMutationException('Cannot change role for user which does not exist.');
4949
}
5050

5151
if ($user->cannot('changeRole', $userToChange)) {
52-
$this->message = 'Insufficient permissions.';
53-
return;
52+
throw new GraphQLMutationException('Insufficient permissions.');
5453
}
5554

5655
$userToChange->admin = $args['role'] === GlobalRole::ADMINISTRATOR;
5756
$userToChange->save();
5857

5958
$this->user = $userToChange;
59+
60+
return $this;
6061
}
6162
}

app/GraphQL/Mutations/ChangeProjectRole.php

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
namespace App\GraphQL\Mutations;
66

77
use App\Enums\ProjectRole;
8+
use App\Exceptions\GraphQLMutationException;
89
use App\Models\Project;
910
use App\Models\User;
1011
use Exception;
1112
use Illuminate\Support\Facades\Validator;
1213
use Illuminate\Validation\Rule;
13-
use Illuminate\Validation\ValidationException;
1414

1515
final class ChangeProjectRole extends AbstractMutation
1616
{
@@ -24,9 +24,10 @@ final class ChangeProjectRole extends AbstractMutation
2424
* role: ProjectRole,
2525
* } $args
2626
*
27-
* @throws ValidationException
27+
* @throws GraphQLMutationException
28+
* @throws Exception
2829
*/
29-
protected function mutate(array $args): void
30+
public function __invoke(null $_, array $args): self
3031
{
3132
Validator::make($args, [
3233
'userId' => [
@@ -44,20 +45,17 @@ protected function mutate(array $args): void
4445
/** @var ?User $user */
4546
$user = auth()->user();
4647
if ($user === null) {
47-
// This should never happen, but we handle the case anyway to make PHPStan happy.
48-
throw new Exception('Attempt to invite user when not signed in.');
48+
throw new GraphQLMutationException('Attempt to invite user when not signed in.');
4949
}
5050

5151
$userToChange = User::find((int) $args['userId']);
5252
if ($userToChange === null) {
53-
$this->message = 'Cannot change role for user which does not exist.';
54-
return;
53+
throw new GraphQLMutationException('Cannot change role for user which does not exist.');
5554
}
5655

5756
$project = Project::find((int) $args['projectId']);
5857
if ($project === null || $user->cannot('changeUserRole', [$project, $userToChange])) {
59-
$this->message = 'This action is unauthorized.';
60-
return;
58+
throw new GraphQLMutationException('This action is unauthorized.');
6159
}
6260

6361
$rowsEdited = $userToChange->projects()->updateExistingPivot($project->id, [
@@ -73,5 +71,7 @@ protected function mutate(array $args): void
7371

7472
$this->user = $userToChange;
7573
$this->project = $project;
74+
75+
return $this;
7676
}
7777
}

app/GraphQL/Mutations/ClaimSite.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace App\GraphQL\Mutations;
66

7+
use App\Exceptions\GraphQLMutationException;
78
use App\Models\Site;
89
use App\Models\User;
910

@@ -15,25 +16,29 @@ final class ClaimSite extends AbstractMutation
1516
/** @param array{
1617
* siteId: int
1718
* } $args
19+
*
20+
* @throws GraphQLMutationException
1821
*/
19-
protected function mutate(array $args): void
22+
public function __invoke(null $_, array $args): self
2023
{
2124
/** @var ?User $user */
2225
$user = auth()->user();
2326

2427
if ($user === null) {
25-
abort(401, 'Authentication required to claim sites.');
28+
throw new GraphQLMutationException('Authentication required to claim sites.');
2629
}
2730

2831
$site = Site::find((int) $args['siteId']);
2932

3033
if ($site === null) {
31-
abort(404, 'Requested site not found.');
34+
throw new GraphQLMutationException('Requested site not found.');
3235
}
3336

3437
$site->maintainers()->syncWithoutDetaching($user);
3538

3639
$this->site = $site;
3740
$this->user = $user;
41+
42+
return $this;
3843
}
3944
}

app/GraphQL/Mutations/CreateGlobalInvitation.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace App\GraphQL\Mutations;
66

77
use App\Enums\GlobalRole;
8+
use App\Exceptions\GraphQLMutationException;
89
use App\Mail\InvitedToCdash;
910
use App\Models\GlobalInvitation;
1011
use App\Models\User;
@@ -26,9 +27,10 @@ final class CreateGlobalInvitation extends AbstractMutation
2627
* role: GlobalRole,
2728
* } $args
2829
*
30+
* @throws GraphQLMutationException
2931
* @throws Exception
3032
*/
31-
protected function mutate(array $args): void
33+
public function __invoke(null $_, array $args): self
3234
{
3335
// This field might not reset when testing since the same mocked request is reused.
3436
$this->invitedUser = null;
@@ -47,20 +49,19 @@ protected function mutate(array $args): void
4749
/** @var ?User $user */
4850
$user = auth()->user();
4951
if ($user === null) {
50-
// This should never happen, but we handle the case anyway to make PHPStan happy.
51-
throw new Exception('Attempt to invite user when not signed in.');
52+
throw new GraphQLMutationException('Attempt to invite user when not signed in.');
5253
}
5354

5455
if ($user->cannot('createInvitation', GlobalInvitation::class)) {
55-
abort(401, 'This action is unauthorized.');
56+
throw new GraphQLMutationException('This action is unauthorized.');
5657
}
5758

5859
if (GlobalInvitation::where('email', $args['email'])->exists()) {
59-
abort(400, 'Duplicate invitations are not allowed.');
60+
throw new GraphQLMutationException('Duplicate invitations are not allowed.');
6061
}
6162

6263
if (User::where('email', $args['email'])->exists()) {
63-
abort(401, 'User is already a member of this instance.');
64+
throw new GraphQLMutationException('User is already a member of this instance.');
6465
}
6566

6667
$password = Str::password();
@@ -76,5 +77,7 @@ protected function mutate(array $args): void
7677
// The email gets sent to the queue, so we have no way to know immediately whether it was sent or not.
7778
// TODO: We should eventually track whether the email was actually sent.
7879
Mail::to($args['email'])->send(new InvitedToCdash($this->invitedUser, $password));
80+
81+
return $this;
7982
}
8083
}

app/GraphQL/Mutations/CreatePinnedTestMeasurement.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ final class CreatePinnedTestMeasurement extends AbstractMutation
1818
* name: string,
1919
* } $args
2020
*/
21-
protected function mutate(array $args): void
21+
public function __invoke(null $_, array $args): self
2222
{
2323
$project = Project::find((int) $args['projectId']);
2424
Gate::authorize('createPinnedTestMeasurement', $project);
@@ -34,5 +34,7 @@ protected function mutate(array $args): void
3434
'name' => $args['name'],
3535
'position' => $nextAvailablePosition,
3636
]);
37+
38+
return $this;
3739
}
3840
}

app/GraphQL/Mutations/DeletePinnedTestMeasurement.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ final class DeletePinnedTestMeasurement extends AbstractMutation
1414
* id: int,
1515
* } $args
1616
*/
17-
protected function mutate(array $args): void
17+
public function __invoke(null $_, array $args): self
1818
{
1919
$measurement = PinnedTestMeasurement::find((int) $args['id']);
2020

2121
Gate::authorize('deletePinnedTestMeasurement', $measurement?->project);
2222

2323
$measurement?->delete();
24+
25+
return $this;
2426
}
2527
}

app/GraphQL/Mutations/InviteToProject.php

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace App\GraphQL\Mutations;
66

77
use App\Enums\ProjectRole;
8+
use App\Exceptions\GraphQLMutationException;
89
use App\Mail\InvitedToProject;
910
use App\Models\Project;
1011
use App\Models\ProjectInvitation;
@@ -26,9 +27,10 @@ final class InviteToProject extends AbstractMutation
2627
* role: ProjectRole,
2728
* } $args
2829
*
30+
* @throws GraphQLMutationException
2931
* @throws Exception
3032
*/
31-
protected function mutate(array $args): void
33+
public function __invoke(null $_, array $args): self
3234
{
3335
// This field might not reset when testing since the same mocked request is reused.
3436
$this->invitedUser = null;
@@ -56,18 +58,15 @@ protected function mutate(array $args): void
5658

5759
$project = isset($args['projectId']) ? Project::find((int) $args['projectId']) : null;
5860
if ($project === null || $user->cannot('inviteUser', $project)) {
59-
$this->message = 'This action is unauthorized.';
60-
return;
61+
throw new GraphQLMutationException('This action is unauthorized.');
6162
}
6263

6364
if (ProjectInvitation::where(['email' => $args['email'], 'project_id' => $args['projectId']])->exists()) {
64-
$this->message = 'Duplicate invitations are not allowed.';
65-
return;
65+
throw new GraphQLMutationException('Duplicate invitations are not allowed.');
6666
}
6767

6868
if ($project->users()->where('email', $args['email'])->exists()) {
69-
$this->message = 'User is already a member of this project.';
70-
return;
69+
throw new GraphQLMutationException('User is already a member of this project.');
7170
}
7271

7372
$this->invitedUser = ProjectInvitation::create([
@@ -81,5 +80,7 @@ protected function mutate(array $args): void
8180
// The email gets sent to the queue, so we have no way to know immediately whether it was sent or not.
8281
// TODO: We should eventually track whether the email was actually sent.
8382
Mail::to($args['email'])->send(new InvitedToProject($this->invitedUser));
83+
84+
return $this;
8485
}
8586
}

app/GraphQL/Mutations/JoinProject.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
namespace App\GraphQL\Mutations;
66

7+
use App\Exceptions\GraphQLMutationException;
78
use App\Models\Project;
89
use App\Models\User;
9-
use Exception;
1010

1111
final class JoinProject extends AbstractMutation
1212
{
@@ -15,9 +15,9 @@ final class JoinProject extends AbstractMutation
1515
* projectId: int,
1616
* } $args
1717
*
18-
* @throws Exception
18+
* @throws GraphQLMutationException
1919
*/
20-
protected function mutate(array $args): void
20+
public function __invoke(null $_, array $args): self
2121
{
2222
/** @var ?User $user */
2323
$user = auth()->user();
@@ -28,7 +28,7 @@ protected function mutate(array $args): void
2828
|| $project === null
2929
|| $user->cannot('join', $project)
3030
) {
31-
abort(401, 'This action is unauthorized.');
31+
throw new GraphQLMutationException('This action is unauthorized.');
3232
}
3333

3434
$project
@@ -40,5 +40,7 @@ protected function mutate(array $args): void
4040
'emailmissingsites' => false,
4141
'role' => Project::PROJECT_USER,
4242
]);
43+
44+
return $this;
4345
}
4446
}

app/GraphQL/Mutations/RemoveProjectUser.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace App\GraphQL\Mutations;
66

7+
use App\Exceptions\GraphQLMutationException;
78
use App\Models\Project;
89
use App\Models\User;
910

@@ -14,8 +15,10 @@ final class RemoveProjectUser extends AbstractMutation
1415
* userId: int,
1516
* projectId: int,
1617
* } $args
18+
*
19+
* @throws GraphQLMutationException
1720
*/
18-
protected function mutate(array $args): void
21+
public function __invoke(null $_, array $args): self
1922
{
2023
/** @var ?User $user */
2124
$user = auth()->user();
@@ -36,9 +39,11 @@ protected function mutate(array $args): void
3639
)
3740
|| !$project->users()->where('id', $userToRemove->id)->exists()
3841
) {
39-
abort(401, 'This action is unauthorized.');
42+
throw new GraphQLMutationException('This action is unauthorized.');
4043
}
4144

4245
$project->users()->detach($userToRemove->id);
46+
47+
return $this;
4348
}
4449
}

0 commit comments

Comments
 (0)