Passed
Push — master ( 4841fc...ceb688 )
by Andrea Marco
03:14 queued 13s
created

JsonApiError   A

Complexity

Total Complexity 24

Size/Duplication

Total Lines 228
Duplicated Lines 0 %

Test Coverage

Coverage 89.29%

Importance

Changes 4
Bugs 0 Features 0
Metric Value
eloc 51
c 4
b 0
f 0
dl 0
loc 228
ccs 50
cts 56
cp 0.8929
rs 10
wmc 24

17 Methods

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