Skip to content

Commit f3fae2f

Browse files
committed
prototype-better-error-handling
1 parent 67c8e08 commit f3fae2f

File tree

4 files changed

+562
-2
lines changed

4 files changed

+562
-2
lines changed

src/Error.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44

55
namespace Chubbyphp\Parsing;
66

7-
final class Error
7+
/**
8+
* @phpstan-type ErrorAsJson array{code: string, template: string, variables: array<string, mixed>}
9+
*/
10+
final class Error implements \JsonSerializable
811
{
912
/**
1013
* @param array<string, mixed> $variables
1114
*/
12-
public function __construct(public string $code, public string $template, public array $variables) {}
15+
public function __construct(public readonly string $code, public readonly string $template, public readonly array $variables) {}
1316

1417
public function __toString()
1518
{
@@ -25,4 +28,12 @@ public function __toString()
2528

2629
return $message;
2730
}
31+
32+
/**
33+
* @return ErrorAsJson
34+
*/
35+
public function jsonSerialize(): array
36+
{
37+
return ['code' => $this->code, 'template' => $this->template, 'variables' => $this->variables];
38+
}
2839
}

src/ErrorWithPath.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Chubbyphp\Parsing;
6+
7+
/**
8+
* @phpstan-type ErrorAsJson array{code: string, template: string, variables: array<string, mixed>}
9+
* @phpstan-type ErrorWithPathJson array{error: ErrorAsJson, path: string}
10+
*/
11+
final class ErrorWithPath implements \JsonSerializable
12+
{
13+
public function __construct(public readonly Error $error, public readonly string $path) {}
14+
15+
public function __toString()
16+
{
17+
return $this->path.': '.(string) $this->error;
18+
}
19+
20+
/**
21+
* @return ErrorWithPathJson
22+
*/
23+
public function jsonSerialize(): array
24+
{
25+
return ['error' => $this->error->jsonSerialize(), 'path' => $this->path];
26+
}
27+
}

src/ErrorsWithPath.php

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Chubbyphp\Parsing;
6+
7+
/**
8+
* @phpstan-type ErrorAsJson array{code: string, template: string, variables: array<string, mixed>}
9+
* @phpstan-type ErrorWithPathJson array{error: ErrorAsJson, path: string}
10+
* @phpstan-type ErrorsWithPathJson array<ErrorWithPathJson>
11+
* @phpstan-type ApiProblem array{name: string, reason: string, details: non-empty-array<string, mixed>}
12+
*/
13+
final class ErrorsWithPath implements \JsonSerializable
14+
{
15+
/**
16+
* @var array<ErrorWithPath>
17+
*/
18+
private array $errorsWithPath = [];
19+
20+
/**
21+
* @param array<Error|ErrorWithPath|self> $errors
22+
*/
23+
public function __construct(array $errors, private string $path = '')
24+
{
25+
foreach ($errors as $error) {
26+
$this->addError($error);
27+
}
28+
}
29+
30+
public function __toString()
31+
{
32+
return implode(PHP_EOL, array_map(static fn (ErrorWithPath $errorWithPath) => (string) $errorWithPath, $this->errorsWithPath));
33+
}
34+
35+
public function addError(Error|ErrorWithPath|self $error): self
36+
{
37+
if ($error instanceof self) {
38+
foreach ($error->errorsWithPath as $errorWithPath) {
39+
$this->errorsWithPath[] = new ErrorWithPath($errorWithPath->error, $this->mergePath($this->path, $errorWithPath->path));
40+
}
41+
} elseif ($error instanceof ErrorWithPath) {
42+
$this->errorsWithPath[] = new ErrorWithPath($error->error, $this->mergePath($this->path, $error->path));
43+
} else {
44+
$this->errorsWithPath[] = new ErrorWithPath($error, $this->path);
45+
}
46+
47+
return $this;
48+
}
49+
50+
/**
51+
* @return array<string, mixed>
52+
*/
53+
public function toTree(): array
54+
{
55+
// @var array<string, mixed> $tree
56+
return array_reduce(
57+
$this->errorsWithPath,
58+
static function (array $tree, $errorWithPath): array {
59+
$pathParts = explode('.', $errorWithPath->path);
60+
61+
$current = &$tree;
62+
$lastIndex = \count($pathParts) - 1;
63+
64+
foreach ($pathParts as $i => $pathPart) {
65+
if ($i < $lastIndex) {
66+
$current[$pathPart] ??= [];
67+
$current = &$current[$pathPart];
68+
69+
continue;
70+
}
71+
72+
$current[$pathPart] = array_merge($current[$pathPart] ?? [], [(string) $errorWithPath->error]);
73+
}
74+
75+
return $tree;
76+
},
77+
[]
78+
);
79+
}
80+
81+
/**
82+
* @return array<ApiProblem>
83+
*/
84+
public function toApiProblems(): array
85+
{
86+
return array_map(
87+
fn (ErrorWithPath $errorWithPath) => [
88+
'name' => $this->pathToName($errorWithPath->path),
89+
'reason' => (string) $errorWithPath->error,
90+
'details' => [
91+
'_template' => $errorWithPath->error->template,
92+
...$errorWithPath->error->variables,
93+
],
94+
],
95+
$this->errorsWithPath
96+
);
97+
}
98+
99+
/**
100+
* @return ErrorsWithPathJson
101+
*/
102+
public function jsonSerialize(): array
103+
{
104+
return array_map(static fn (ErrorWithPath $errorWithPath) => $errorWithPath->jsonSerialize(), $this->errorsWithPath);
105+
}
106+
107+
private function mergePath(string $path, string $existingPath): string
108+
{
109+
return '' === $path ? $existingPath : $path.'.'.$existingPath;
110+
}
111+
112+
private function pathToName(string $path): string
113+
{
114+
$pathParts = explode('.', $path);
115+
116+
return implode('', array_map(
117+
static fn (string $pathPart, $i) => 0 === $i ? $pathPart : '['.$pathPart.']',
118+
$pathParts,
119+
array_keys($pathParts)
120+
));
121+
}
122+
}

0 commit comments

Comments
 (0)