Passed
Push — master ( bfb86c...c8fff0 )
by Andrea Marco
13:26 queued 14s
created

JsonApiError   A

Complexity

Total Complexity 25

Size/Duplication

Total Lines 249
Duplicated Lines 0 %

Test Coverage

Coverage 81.97%

Importance

Changes 5
Bugs 0 Features 0
Metric Value
eloc 56
dl 0
loc 249
ccs 50
cts 61
cp 0.8197
rs 10
c 5
b 0
f 0
wmc 25

18 Methods

Rating   Name   Duplication   Size   Complexity  
A handle() 0 12 4
A handleRequestIf() 0 3 1
A fromValidation() 0 11 3
A unexpected() 0 3 1
A toResponse() 0 3 1
A fallbackRoute() 0 7 1
A mapToStatus() 0 3 1
A toArray() 0 9 2
A throw() 0 3 1
A handlesRequest() 0 3 2
A merge() 0 3 1
A __construct() 0 6 1
A fromJsonApiErrorsAware() 0 3 1
A fromHttpException() 0 3 1
A from() 0 9 1
A fromJsonApiSafe() 0 3 1
A raw() 0 3 1
A fromStatus() 0 3 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Cerbero\JsonApiError;
6
7
use Cerbero\JsonApiError\Contracts\JsonApiErrorsAware;
8
use Cerbero\JsonApiError\Contracts\JsonApiSafe;
9
use Cerbero\JsonApiError\Data\JsonApiErrorData;
10
use Cerbero\JsonApiError\Exceptions\InvalidHandlerException;
11
use Cerbero\JsonApiError\Exceptions\JsonApiException;
12
use Cerbero\JsonApiError\Exceptions\NoErrorsException;
13
use Cerbero\JsonApiError\Middleware\EnforceJsonRequest;
14
use Closure;
15
use Illuminate\Contracts\Support\Responsable;
16
use Illuminate\Http\JsonResponse;
17
use Illuminate\Http\Request;
18
use Illuminate\Support\Arr;
19
use Illuminate\Support\Facades\Route;
20
use Illuminate\Support\Reflector;
21
use Illuminate\Validation\ValidationException;
22
use ReflectionFunction;
23
use Symfony\Component\HttpFoundation\Response;
24
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
25
use Throwable;
26
27
/**
28
 * The entry-point to render JSON:API compliant errors.
29
 */
30
final class JsonApiError implements Responsable
31
{
32
    /**
33
     * The map between throwables and handlers.
34
     *
35
     * @var array<class-string, callable>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<class-string, callable> at position 2 could not be parsed: Unknown type name 'class-string' at position 2 in array<class-string, callable>.
Loading history...
36
     */
37
    private static array $handlersMap = [
38
        \Cerbero\JsonApiError\Contracts\JsonApiErrorsAware::class => [self::class, 'fromJsonApiErrorsAware'],
39
        \Cerbero\JsonApiError\Contracts\JsonApiSafe::class => [self::class, 'fromJsonApiSafe'],
40
        \Illuminate\Validation\ValidationException::class => [self::class, 'fromValidation'],
41
        \Symfony\Component\HttpKernel\Exception\HttpException::class => [self::class, 'fromHttpException'],
42
    ];
43
44
    /**
45
     * The map between throwables and HTTP statuses.
46
     *
47
     * @var array<class-string, int>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<class-string, int> at position 2 could not be parsed: Unknown type name 'class-string' at position 2 in array<class-string, int>.
Loading history...
48
     */
49
    private static array $statusesMap = [
50
        \Illuminate\Auth\Access\AuthorizationException::class => 403,
51
        \Illuminate\Auth\AuthenticationException::class => 401,
52
        \Illuminate\Database\Eloquent\ModelNotFoundException::class => 404,
53
        \Illuminate\Validation\UnauthorizedException::class => 403,
54
    ];
55
56
    /**
57
     * The user-defined logic to determine whether the current request should be handled.
58
     *
59
     * @var ?Closure(Request): bool
60
     */
61
    private static ?Closure $handleRequestIf = null;
62
63
    /**
64
     * The user-defined data to merge with all JSON:API errors.
65
     *
66
     * @var ?Closure(): JsonApiErrorData
67
     */
68
    private static ?Closure $merge = null;
69
70
    /**
71
     * The middleware to ensure JSON requests.
72
     *
73
     * @var class-string
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
74
     */
75
    public static string $middleware = EnforceJsonRequest::class;
76
77
    /**
78
     * The JSON:API errors.
79
     *
80
     * @var JsonApiErrorData[] $errors
81
     */
82
    public readonly array $errors;
83
84
    /**
85
     * Instantiate the class.
86
     */
87 12
    public function __construct(JsonApiErrorData ...$errors)
88
    {
89 11
        $this->errors = match (true) {
90 12
            empty($errors) => throw new NoErrorsException(),
91 11
            is_null($merge = self::$merge) => $errors,
92 1
            default => array_map(fn(JsonApiErrorData $error) => $error->merge($merge()), $errors),
93 11
        };
94
    }
95
96
    /**
97
     * Define a custom logic to determine whether the given request should be handled.
98
     *
99
     * @param Closure(Request): bool $callback
100
     */
101
    public static function handleRequestIf(Closure $callback): void
102
    {
103
        self::$handleRequestIf = $callback;
104
    }
105
106
    /**
107
     * Determine whether the given request should be handled.
108
     */
109 11
    public static function handlesRequest(Request $request): bool
110
    {
111 11
        return self::$handleRequestIf ? (self::$handleRequestIf)($request) : $request->expectsJson();
112
    }
113
114
    /**
115
     * Define a custom handler to turn the given throwable into JSON:API error(s).
116
     *
117
     * @template T of Throwable
118
     * @param Closure(T): (JsonApiErrorData|JsonApiErrorData[]) $handler
119
     */
120 2
    public static function handle(Closure $handler): void
121
    {
122 2
        $parameters = (new ReflectionFunction($handler))->getParameters();
123
        /** @var class-string[] */
124 2
        $types = empty($parameters) ? [''] : (Reflector::getParameterClassNames($parameters[0]) ?: ['']);
125
126 2
        foreach ($types as $type) {
127 2
            throw_unless(is_subclass_of($type, Throwable::class), InvalidHandlerException::class);
128
129 1
            self::$handlersMap[$type] = function (Throwable $e) use ($handler) {
130
                /** @var T $e */
131 1
                return new self(...Arr::wrap($handler($e)));
132 1
            };
133
        }
134
    }
135
136
    /**
137
     * Map the given throwable to the provided HTTP status.
138
     *
139
     * @param class-string $throwable
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string.
Loading history...
140
     */
141 1
    public static function mapToStatus(string $throwable, int $status): void
142
    {
143 1
        self::$statusesMap[$throwable] = $status;
144
    }
145
146
    /**
147
     * Define custom data to merge with all JSON:API errors.
148
     *
149
     * @param Closure(): JsonApiErrorData $callback
150
     */
151 1
    public static function merge(Closure $callback): void
152
    {
153 1
        self::$merge = $callback;
154
    }
155
156
    /**
157
     * Instantiate the class from the given raw error.
158
     */
159 4
    public static function raw(?string $detail = null, int $status = Response::HTTP_BAD_REQUEST): self
160
    {
161 4
        return new self(new JsonApiErrorData($detail, $status));
162
    }
163
164
    /**
165
     * Instantiate the class from the given throwable.
166
     */
167 10
    public static function from(Throwable $e): self
168
    {
169
        /** @var ?callable */
170 10
        $handler = Arr::first(self::$handlersMap, fn(callable $h, string $class) => is_a($e, $class));
171
172 10
        return match (true) {
173 6
            $handler !== null => $handler($e),
174 4
            isset(self::$statusesMap[$e::class]) => self::fromStatus(self::$statusesMap[$e::class]),
175 10
            default => self::unexpected($e),
176 10
        };
177
    }
178
179
    /**
180
     * Instantiate the class from the given HTTP status.
181
     */
182 6
    public static function fromStatus(int $status): self
183
    {
184 6
        return new self(new JsonApiErrorData(status: $status));
185
    }
186
187
    /**
188
     * Instantiate the class from the given unexpected throwable.
189
     */
190 1
    public static function unexpected(Throwable $e): self
191
    {
192 1
        return new self(JsonApiErrorData::unexpected($e));
193
    }
194
195
    /**
196
     * Instantiate the class from the given instance aware of JSON:API errors.
197
     */
198
    public static function fromJsonApiErrorsAware(JsonApiErrorsAware $instance): self
199
    {
200
        return new self(...$instance->jsonApiErrors());
201
    }
202
203
    /**
204
     * Instantiate the class from the given JSON:API renderable.
205
     */
206
    public static function fromJsonApiSafe(JsonApiSafe $e): self
207
    {
208
        return new self(new JsonApiErrorData($e->getMessage(), max($e->getCode(), Response::HTTP_BAD_REQUEST)));
209
    }
210
211
    /**
212
     * Instantiate the class from the given validation exception.
213
     */
214 2
    public static function fromValidation(ValidationException $e): self
215
    {
216 2
        $errors = [];
217
218 2
        foreach ($e->errors() as $dot => $details) {
219 2
            foreach ($details as $detail) {
220 2
                $errors[] = JsonApiErrorData::unprocessable($detail, $dot);
221
            }
222
        }
223
224 2
        return new self(...$errors);
225
    }
226
227
    /**
228
     * Instantiate the class from the given HTTP exception.
229
     */
230 3
    public static function fromHttpException(HttpExceptionInterface $e): self
231
    {
232 3
        return self::fromStatus($e->getStatusCode());
233
    }
234
235
    /**
236
     * Register a fallback route for missing JSON endpoints.
237
     */
238
    public static function fallbackRoute(): void
239
    {
240
        $placeholder = 'fallbackPlaceholder';
241
242
        Route::get("{{$placeholder}?}", fn() => self::fromStatus(Response::HTTP_NOT_FOUND))
243
            ->where($placeholder, '.*')
244
            ->fallback();
245
    }
246
247
    /**
248
     * Retrieve the HTTP response containing the JSON:API errors.
249
     *
250
     * @param \Illuminate\Http\Request $request
251
     */
252 11
    public function toResponse($request): Response
253
    {
254 11
        return new JsonResponse($this->toArray(), $this->errors[0]->status);
255
    }
256
257
    /**
258
     * Retrieve the formatted JSON:API errors.
259
     *
260
     * @return array<string, array<int, array<string, mixed>>>
261
     */
262 11
    public function toArray(): array
263
    {
264 11
        $data = [];
265
266 11
        foreach ($this->errors as $error) {
267 11
            $data['errors'][] = array_filter($error->toArray(), filled(...));
268
        }
269
270 11
        return $data;
271
    }
272
273
    /**
274
     * Stop the code flow to render the JSON:API errors.
275
     */
276 1
    public function throw(): never
277
    {
278 1
        throw new JsonApiException(...$this->errors);
279
    }
280
}
281