Skip to content

Commit f1e85a5

Browse files
committed
better rfc7807 client implementation
1 parent 5b2ffde commit f1e85a5

File tree

10 files changed

+168
-61
lines changed

10 files changed

+168
-61
lines changed

.github/README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ Deprecated or Sunset headers will be logged into defined log channel.
5454
$deprecationsLogger = new \SimpleAsFuck\ApiToolkit\Service\Client\DeprecationsLogger(
5555
$config,
5656
$logger,
57-
new \GuzzleHttp\Psr7\HttpFactory()
5857
);
5958

6059
$client = new \SimpleAsFuck\ApiToolkit\Service\Client\ApiClient(
@@ -103,8 +102,19 @@ catch (\SimpleAsFuck\ApiToolkit\Model\Client\ApiException $exception) {
103102
* \SimpleAsFuck\ApiToolkit\Model\Client\ApiException is thrown,
104103
* and you can handle any error from communication
105104
*/
106-
$exception->getCode(); // if exception contains http response, http status is here, otherwise zero is returned
107-
$exception->getMessage(); // if http response contains json object with message string property, json message overwrite exception message
105+
// if exception contains http response, rfc7807 status or http status is here, otherwise zero is returned
106+
$exception->getCode();
107+
// exception message for logging or debugging is build from https://datatracker.ietf.org/doc/html/rfc7807
108+
// extended with optional message property, you SHOULD log this, so you know WTF is going wrong
109+
$logger->error($exception->getMessage());
110+
// short information for end user WTF just happened, if is not null you CAN show the tittle on your front end
111+
$exception->getTitle();
112+
// information for end user with more detail, if is not null you SHOULD show the detail on your front end,
113+
// because detail can contain clue or information how user can solve error, mainly if error is his false :D
114+
$exception->getDetail();
115+
// parse from error response some extensions, is RECOMMENDED ignoring all errors from error response parsing
116+
// because you can lose another useful data from error response or if response si corrupted you can lose previous exception
117+
$exception->response()?->getJson(allowInvalidJson: true)->object()->property('some_error_property')->string()->nullable(failAsNull: true);
108118
}
109119

110120
```

src/Factory/Client/ParseResponseException.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ public function __construct(
1818

1919
public function create(string $message): \Exception
2020
{
21-
return new \SimpleAsFuck\ApiToolkit\Model\Client\ParseResponseException($message, $this->request, $this->response);
21+
return new \SimpleAsFuck\ApiToolkit\Model\Client\ParseResponseException(
22+
$message,
23+
$this->response->getStatusCode(),
24+
$this->request->url(),
25+
null,
26+
null,
27+
null,
28+
$this->request,
29+
$this->response
30+
);
2231
}
2332
}

src/Model/Client/ApiException.php

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,58 @@
66

77
class ApiException extends \RuntimeException
88
{
9+
/**
10+
* @param string $message for logging or debugging purposes MUST contain only English message
11+
* @param int $code https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 status or HTTP status or 0 if nothing is available
12+
* @param non-empty-string $instance https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 instance
13+
* @param non-empty-string|null $type https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 type, if not available look at message
14+
* @param non-empty-string|null $title https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 title message for end user
15+
* @param non-empty-string|null $detail https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 detail for end user
16+
*/
917
public function __construct(
1018
string $message,
19+
int $code,
20+
private readonly string $instance,
21+
private readonly ?string $type,
22+
private readonly ?string $title,
23+
private readonly ?string $detail,
1124
private readonly Request $request,
12-
private readonly ?Response $response = null,
13-
\Throwable $previous = null
25+
private readonly ?Response $response,
26+
?\Throwable $previous = null
1427
) {
15-
parent::__construct($message, $response?->getStatusCode() ?? 0, $previous);
28+
parent::__construct($message, $code, $previous);
29+
}
30+
31+
/**
32+
* @return non-empty-string|null
33+
*/
34+
public function getInstance(): ?string
35+
{
36+
return $this->instance;
37+
}
38+
39+
/**
40+
* @return non-empty-string|null
41+
*/
42+
public function getType(): ?string
43+
{
44+
return $this->type;
45+
}
46+
47+
/**
48+
* @return non-empty-string|null
49+
*/
50+
public function getTitle(): ?string
51+
{
52+
return $this->title;
53+
}
54+
55+
/**
56+
* @return non-empty-string|null
57+
*/
58+
public function getDetail(): ?string
59+
{
60+
return $this->detail;
1661
}
1762

1863
public function request(): Request

src/Model/Client/Request.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,22 @@ public function withJson(mixed $jsonData, ?Transformer $transformer, int $jsonEn
8787
return $request;
8888
}
8989

90+
/**
91+
* @return non-empty-string
92+
*/
93+
public function method(): string
94+
{
95+
return $this->method;
96+
}
97+
98+
/**
99+
* @return non-empty-string
100+
*/
101+
public function url(): string
102+
{
103+
return $this->url;
104+
}
105+
90106
public function hasBaseUrl(): bool
91107
{
92108
return $this->baseUrl !== null;

src/Model/Client/ResponseApiException.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,26 @@
66

77
class ResponseApiException extends ApiException
88
{
9+
/**
10+
* @param string $message for logging or debugging purposes MUST contain only English message
11+
* @param int $code https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 status or HTTP status
12+
* @param non-empty-string $instance https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 instance
13+
* @param non-empty-string|null $type https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 type, if not available look at message
14+
* @param non-empty-string|null $title https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 title message for end user
15+
* @param non-empty-string|null $detail https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 detail for end user
16+
*/
917
final public function __construct(
1018
string $message,
19+
int $code,
20+
string $instance,
21+
?string $type,
22+
?string $title,
23+
?string $detail,
1124
Request $request,
1225
private readonly Response $response,
13-
\Throwable $previous = null
26+
?\Throwable $previous = null
1427
) {
15-
parent::__construct($message, $request, $response, $previous);
28+
parent::__construct($message, $code, $instance, $type, $title, $detail, $request, $response, $previous);
1629
}
1730

1831
final public function response(): Response

src/Provider/LaravelProvider.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,20 +33,20 @@ public function register(): void
3333
$config = $this->app->make(\Illuminate\Contracts\Config\Repository::class);
3434
/** @var Config $apiConfig */
3535
$apiConfig = $this->app->make(Config::class);
36-
/** @var RequestFactoryInterface $requestFactory */
37-
$requestFactory = $this->app->make(RequestFactoryInterface::class);
3836

3937
$deprecationLogger = $config->get('logging.deprecations');
4038
if (is_string($deprecationLogger)) {
4139
/** @var LogManager $logManager */
4240
$logManager = $this->app->make(LogManager::class);
43-
$deprecationLogger = new DeprecationsLogger($apiConfig, $logManager->channel($deprecationLogger), $requestFactory);
41+
$deprecationLogger = new DeprecationsLogger($apiConfig, $logManager->channel($deprecationLogger));
4442
} else {
4543
$deprecationLogger = null;
4644
}
4745

4846
/** @var Client $client */
4947
$client = $this->app->make(Client::class);
48+
/** @var RequestFactoryInterface $requestFactory */
49+
$requestFactory = $this->app->make(RequestFactoryInterface::class);
5050

5151
return new ApiClient($apiConfig, $client, $requestFactory, $deprecationLogger);
5252
});

src/Service/Client/ApiClient.php

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -190,62 +190,62 @@ public function waitRaw(ResponsePromise $promise): Response
190190

191191
$responseContent = $response->getBody()->getContents();
192192
$errorObject = Validator::make(\json_decode($responseContent))->object();
193-
$errorParts = [];
193+
$messageParts = [];
194194

195-
// https://datatracker.ietf.org/doc/html/rfc7807
195+
// https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 type
196196
$errorType = $errorObject->property('type')->string()->notEmpty()->nullable(true);
197197
if ($errorType !== null) {
198-
$errorParts[] = 'Error type: "'.$errorType.'"';
198+
$messageParts[] = 'Error type: "'.$errorType.'"';
199199
}
200200

201-
$errorMessage =
202-
$errorObject
203-
->property('title')
204-
->string()
205-
->notEmpty()
206-
->nullable(true)
207-
??
208-
$errorObject
209-
->property('message')
210-
->string()
211-
->notEmpty()
212-
->nullable(true)
213-
;
201+
$errorMessage = $errorObject->property('message')->string()->notEmpty()->nullable(true);
214202
if ($errorMessage !== null) {
215-
$errorParts[] = $errorMessage;
203+
$messageParts[] = $errorMessage;
216204
}
217205

218-
if (count($errorParts) !== 0) {
219-
$errorStatus = $errorObject->property('status')->int()->nullable(true);
220-
if ($errorStatus !== null) {
221-
$errorParts[] = 'status ('.$errorStatus.')';
206+
// https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 status
207+
$errorStatus = $errorObject->property('status')->int()->nullable(true) ?? $response->getStatusCode();
208+
// https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 instance
209+
$errorInstance = $errorObject->property('instance')->string()->notEmpty()->nullable(true) ?? $promise->request->url();
210+
// https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 title
211+
$errorTitle = $errorObject->property('title')->string()->notEmpty()->nullable(true);
212+
// https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 detail
213+
$errorDetail = $errorObject->property('detail')->string()->notEmpty()->nullable(true);
214+
215+
if (count($messageParts) === 0) {
216+
if ($errorTitle !== null) {
217+
$messageParts[] = 'Error title: "'.$errorTitle.'"';
222218
}
223-
$errorInstance = $errorObject->property('instance')->string()->notEmpty()->nullable(true);
224-
if ($errorInstance !== null) {
225-
$errorParts[] = 'instance: "'.$errorInstance.'"';
219+
if ($errorDetail !== null) {
220+
$messageParts[] = 'Error detail: "'.$errorDetail.'"';
226221
}
222+
}
223+
224+
if (count($messageParts) !== 0) {
225+
$messageParts[] = 'status (' . $errorStatus . ')';
226+
$messageParts[] = 'instance: "' . $errorInstance . '"';
227227

228-
$message = implode(' ', $errorParts);
228+
$message = implode(' ', $messageParts);
229229
}
230230

231231
$response = $response->withBody((new HttpFactory())->createStream($responseContent));
232232
$response = new Response($promise->request, $response);
233233

234234
match ($response->getStatusCode()) {
235-
HttpCodes::HTTP_BAD_REQUEST => throw new BadRequestApiException($message, $promise->request, $response, $exception),
236-
HttpCodes::HTTP_UNAUTHORIZED => throw new UnauthorizedApiException($message, $promise->request, $response, $exception),
237-
HttpCodes::HTTP_FORBIDDEN => throw new ForbiddenApiException($message, $promise->request, $response, $exception),
238-
HttpCodes::HTTP_NOT_FOUND => throw new NotFoundApiException($message, $promise->request, $response, $exception),
239-
HttpCodes::HTTP_CONFLICT => throw new ConflictApiException($message, $promise->request, $response, $exception),
240-
HttpCodes::HTTP_GONE => throw new GoneApiException($message, $promise->request, $response, $exception),
241-
HttpCodes::HTTP_INTERNAL_SERVER_ERROR => throw new InternalServerErrorApiException($message, $promise->request, $response, $exception),
242-
default => throw new ResponseApiException($message, $promise->request, $response, $exception),
235+
HttpCodes::HTTP_BAD_REQUEST => throw new BadRequestApiException($message, $errorStatus, $errorInstance, $errorType, $errorTitle, $errorDetail, $promise->request, $response, $exception),
236+
HttpCodes::HTTP_UNAUTHORIZED => throw new UnauthorizedApiException($message, $errorStatus, $errorInstance, $errorType, $errorTitle, $errorDetail, $promise->request, $response, $exception),
237+
HttpCodes::HTTP_FORBIDDEN => throw new ForbiddenApiException($message, $errorStatus, $errorInstance, $errorType, $errorTitle, $errorDetail, $promise->request, $response, $exception),
238+
HttpCodes::HTTP_NOT_FOUND => throw new NotFoundApiException($message, $errorStatus, $errorInstance, $errorType, $errorTitle, $errorDetail, $promise->request, $response, $exception),
239+
HttpCodes::HTTP_CONFLICT => throw new ConflictApiException($message, $errorStatus, $errorInstance, $errorType, $errorTitle, $errorDetail, $promise->request, $response, $exception),
240+
HttpCodes::HTTP_GONE => throw new GoneApiException($message, $errorStatus, $errorInstance, $errorType, $errorTitle, $errorDetail, $promise->request, $response, $exception),
241+
HttpCodes::HTTP_INTERNAL_SERVER_ERROR => throw new InternalServerErrorApiException($message, $errorStatus, $errorInstance, $errorType, $errorTitle, $errorDetail, $promise->request, $response, $exception),
242+
default => throw new ResponseApiException($message, $errorStatus, $errorInstance, $errorType, $errorTitle, $errorDetail, $promise->request, $response, $exception),
243243
};
244244
}
245245

246-
throw new ApiException($message, $promise->request, null, $exception);
246+
throw new ApiException($message, 0, $promise->request->url(), null, null, null, $promise->request, $response, $exception);
247247
} catch (TransferException $exception) {
248-
throw new ApiException($exception->getMessage(), $promise->request, null, $exception);
248+
throw new ApiException($exception->getMessage(), 0, $promise->request->url(), null, null, null, $promise->request, null, $exception);
249249
}
250250

251251
$this->deprecationsLogger?->logDeprecation($promise->apiName, $promise->request, $response);

src/Service/Client/DeprecationsLogger.php

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

55
namespace SimpleAsFuck\ApiToolkit\Service\Client;
66

7-
use Psr\Http\Message\RequestFactoryInterface;
87
use Psr\Http\Message\ResponseInterface;
98
use Psr\Log\LoggerInterface;
109
use SimpleAsFuck\ApiToolkit\Model\Client\Request;
@@ -16,7 +15,6 @@ class DeprecationsLogger
1615
public function __construct(
1716
private readonly Config $config,
1817
private readonly LoggerInterface $logger,
19-
private readonly RequestFactoryInterface $requestFactory
2018
) {
2119
}
2220

@@ -59,8 +57,7 @@ public function logDeprecation(string $apiName, Request $request, ResponseInterf
5957
}
6058

6159
if (count($deprecatedContext) !== 0) {
62-
$request = $request->createPsr($this->requestFactory);
63-
$this->logger->warning('Api: '.$apiName.' method: '.$request->getMethod().' uri: "'.$request->getUri()->__toString().'" call is deprecated', $deprecatedContext);
60+
$this->logger->warning('Api: '.$apiName.' method: '.$request->method().' url: "'.$request->url().'" call is deprecated', $deprecatedContext);
6461
}
6562
}
6663
}

test/Service/Client/ApiClientTest.php

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public static function dataProviderWaitRawFail(): array
6363
[400, 'Exception message', '', new RequestException('Exception message', $request, $response)],
6464
[
6565
400,
66-
'Json message',
66+
'Json message status (400) instance: "/"',
6767
'{"message":"Json message"}',
6868
new RequestException(
6969
'Exception message',
@@ -73,7 +73,7 @@ public static function dataProviderWaitRawFail(): array
7373
],
7474
[
7575
400,
76-
'Json title',
76+
'Error title: "Json title" status (400) instance: "/"',
7777
'{"title":"Json title"}',
7878
new RequestException(
7979
'Exception message',
@@ -83,14 +83,34 @@ public static function dataProviderWaitRawFail(): array
8383
],
8484
[
8585
400,
86-
'Error type: "/test/error" Json title status (401) instance: "/test/url"',
86+
'Json message status (400) instance: "/"',
87+
'{"title":"Json title","message":"Json message"}',
88+
new RequestException(
89+
'Exception message',
90+
$request,
91+
$response->withBody($httpFactory->createStream('{"title":"Json title","message":"Json message"}'))
92+
),
93+
],
94+
[
95+
401,
96+
'Error type: "/test/error" status (401) instance: "/test/url"',
8797
'{"title":"Json title","type":"/test/error","status":401,"instance":"/test/url"}',
8898
new RequestException(
8999
'Exception message',
90100
$request,
91101
$response->withBody($httpFactory->createStream('{"title":"Json title","type":"/test/error","status":401,"instance":"/test/url"}'))
92102
),
93103
],
104+
[
105+
403,
106+
'Error type: "/test/error" Json message status (403) instance: "/"',
107+
'{"type":"/test/error","message":"Json message"}',
108+
new RequestException(
109+
'Exception message',
110+
$request,
111+
$response->withStatus(403)->withBody($httpFactory->createStream('{"type":"/test/error","message":"Json message"}'))
112+
),
113+
],
94114
];
95115
}
96116
}

0 commit comments

Comments
 (0)