Skip to content

Commit b370208

Browse files
committed
JSON Lines client response side support
1 parent 676ea16 commit b370208

File tree

13 files changed

+510
-66
lines changed

13 files changed

+510
-66
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"psr/http-message": "^1.1|^2.0",
77
"psr/http-factory": "^1.1",
88
"psr/http-server-handler": "^1.0",
9-
"simple-as-fuck/php-validator": "^0.7.0",
9+
"simple-as-fuck/php-validator": "^0.7.8",
1010
"guzzlehttp/guzzle": "^7.4",
1111
"illuminate/bus": "^8.81|^9.0|^10.0|^11.0|^12.0",
1212
"illuminate/database": "^8.81|^9.0|^10.0|^11.0|^12.0",

src/Data/Client/Stream.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 SimpleAsFuck\ApiToolkit\Data\Client;
6+
7+
/**
8+
* @template Tout
9+
*/
10+
final readonly class Stream
11+
{
12+
/**
13+
* @param \Iterator<int, Tout> $iterator
14+
*/
15+
public function __construct(
16+
private \Iterator $iterator,
17+
) {
18+
}
19+
20+
/**
21+
* @return \Iterator<int, Tout>
22+
*/
23+
public function notNull(): \Iterator
24+
{
25+
return $this->iterator;
26+
}
27+
}

src/Data/Client/StreamRules.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleAsFuck\ApiToolkit\Data\Client;
6+
7+
use SimpleAsFuck\ApiToolkit\Data\Transformation\Iterator;
8+
use SimpleAsFuck\Validator\Rule\Custom\UserClassRule;
9+
use SimpleAsFuck\Validator\Rule\General\Rules;
10+
use SimpleAsFuck\Validator\Rule\Object\ObjectRule;
11+
12+
final readonly class StreamRules
13+
{
14+
/**
15+
* @param \Iterator<int, Rules> $iterator
16+
*/
17+
public function __construct(
18+
private \Iterator $iterator,
19+
) {
20+
}
21+
22+
/**
23+
* @return \Iterator<int, mixed>
24+
*/
25+
public function notNull(): \Iterator
26+
{
27+
return new Iterator($this->iterator, static fn (Rules $rules): mixed => $rules->nullable());
28+
}
29+
30+
/**
31+
* @template TMapped
32+
* @param callable(Rules): TMapped $callable
33+
* @return Stream<TMapped>
34+
*/
35+
public function of(callable $callable): Stream
36+
{
37+
return new Stream(new Iterator($this->iterator, $callable));
38+
}
39+
40+
/**
41+
* @template TClass of object
42+
* @param UserClassRule<TClass> $rule
43+
* @return Stream<TClass>
44+
*/
45+
public function ofClass(UserClassRule $rule): Stream
46+
{
47+
return $this->of(static fn (Rules $rules): object => $rules->object()->class($rule)->notNull());
48+
}
49+
50+
/**
51+
* @return Stream<ObjectRule>
52+
*/
53+
public function ofObject(): Stream
54+
{
55+
return $this->of(static fn (Rules $rules): object => $rules->object());
56+
}
57+
58+
public function valid(): bool
59+
{
60+
return $this->iterator->valid();
61+
}
62+
63+
public function fetch(): Rules
64+
{
65+
$rules = $this->iterator->current();
66+
$this->iterator->next();
67+
return $rules;
68+
}
69+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleAsFuck\ApiToolkit\Data\Transformation;
6+
7+
/**
8+
* @template Tin
9+
* @template Tout
10+
* @implements \Iterator<array-key, Tout>
11+
*/
12+
final class Iterator implements \Iterator
13+
{
14+
/**
15+
* @param \Iterator<array-key, Tin> $iterator
16+
* @param callable(Tin): Tout $callable
17+
*/
18+
public function __construct(
19+
private readonly \Iterator $iterator,
20+
private readonly mixed $callable,
21+
) {
22+
}
23+
24+
/**
25+
* @return Tout
26+
*/
27+
public function current(): mixed
28+
{
29+
return ($this->callable)($this->iterator->current());
30+
}
31+
32+
public function next(): void
33+
{
34+
$this->iterator->next();
35+
}
36+
37+
public function key(): mixed
38+
{
39+
return $this->iterator->key();
40+
}
41+
42+
public function valid(): bool
43+
{
44+
return $this->iterator->valid();
45+
}
46+
47+
public function rewind(): void
48+
{
49+
$this->iterator->rewind();
50+
}
51+
}

src/Model/Client/Response.php

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
use Psr\Http\Message\ResponseInterface;
88
use Psr\Http\Message\StreamInterface;
99
use SimpleAsFuck\ApiToolkit\Data\Client\ApiException;
10+
use SimpleAsFuck\ApiToolkit\Data\Client\StreamRules;
1011
use SimpleAsFuck\ApiToolkit\Factory\Client\ParseResponseException;
11-
use SimpleAsFuck\ApiToolkit\Service\Http\MessageService;
12+
use SimpleAsFuck\ApiToolkit\Service\Common\JsonService;
1213
use SimpleAsFuck\Validator\Rule\General\Rules;
1314

1415
final class Response implements ResponseInterface
@@ -117,7 +118,30 @@ public function getBody(): StreamInterface
117118
*/
118119
public function getJson(bool $allowInvalidJson = false, int $jsonDecodeFlags = 0): Rules
119120
{
120-
return MessageService::parseJsonFromBody(new ParseResponseException($this->request, $this), $this->response, 'Response body', $allowInvalidJson, $jsonDecodeFlags);
121+
return JsonService::jsonDecode(
122+
$this->response->getBody()->getContents(),
123+
'Response body',
124+
new ParseResponseException($this->request, $this),
125+
$allowInvalidJson,
126+
$jsonDecodeFlags
127+
);
128+
}
129+
130+
/**
131+
* @param int $jsonDecodeFlags bitmask https://www.php.net/manual/en/function.json-decode.php
132+
* @throws ApiException
133+
*/
134+
public function getJsonl(bool $allowInvalidJson = false, int $jsonDecodeFlags = 0): StreamRules
135+
{
136+
return new StreamRules(
137+
JsonService::jsonlDecode(
138+
$this->response->getBody(),
139+
'Response body',
140+
new ParseResponseException($this->request, $this),
141+
$allowInvalidJson,
142+
$jsonDecodeFlags,
143+
),
144+
);
121145
}
122146

123147
public function getStatusCode(): int

src/Model/Server/RequestRules.php

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

77
use Psr\Http\Message\ServerRequestInterface;
88
use SimpleAsFuck\ApiToolkit\Data\Webhook\WebhookRules;
9-
use SimpleAsFuck\ApiToolkit\Service\Http\MessageService;
9+
use SimpleAsFuck\ApiToolkit\Service\Common\JsonService;
1010
use SimpleAsFuck\ApiToolkit\Service\Webhook\WebhookTransformer;
1111
use SimpleAsFuck\Validator\Factory\Exception;
1212
use SimpleAsFuck\Validator\Model\Validated;
@@ -40,7 +40,7 @@ public function query(): QueryRule
4040
*/
4141
public function json(bool $allowInvalidJson = false, int $jsonDecodeFlags = 0): Rules
4242
{
43-
return MessageService::parseJsonFromBody($this->exceptionFactory, $this->request, 'Request body', $allowInvalidJson, $jsonDecodeFlags);
43+
return JsonService::jsonDecode($this->request->getBody()->getContents(), 'Request body', $this->exceptionFactory, $allowInvalidJson, $jsonDecodeFlags);
4444
}
4545

4646
/**

src/Service/Common/JsonService.php

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleAsFuck\ApiToolkit\Service\Common;
6+
7+
use Psr\Http\Message\StreamInterface;
8+
use SimpleAsFuck\Validator\Factory\Exception;
9+
use SimpleAsFuck\Validator\Factory\UnexpectedValueException;
10+
use SimpleAsFuck\Validator\Model\Validated;
11+
use SimpleAsFuck\Validator\Rule\General\Rules;
12+
13+
class JsonService
14+
{
15+
/**
16+
* @param non-empty-string $stringName
17+
* @param int $jsonDecodeFlags bitmask https://www.php.net/manual/en/function.json-decode.php
18+
*/
19+
public static function jsonDecode(
20+
string $string,
21+
string $stringName = 'String',
22+
Exception $exceptionFactory = new UnexpectedValueException(),
23+
bool $allowInvalidJson = false,
24+
int $jsonDecodeFlags = 0,
25+
): Rules {
26+
$content = \json_decode($string, flags: $jsonDecodeFlags);
27+
if (\json_last_error() !== JSON_ERROR_NONE) {
28+
if ($allowInvalidJson) {
29+
$content = null;
30+
} else {
31+
$truncated = strlen($string) > 200;
32+
$logString = $truncated ? substr($string, 0, 200) : $string;
33+
throw $exceptionFactory->create($stringName.' must be valid json, invalid content: \''.$logString.'\''.($truncated ? ' (truncated)' : ''));
34+
}
35+
}
36+
37+
return new Rules($exceptionFactory, $stringName.' json', new Validated($content));
38+
}
39+
40+
/**
41+
* @param non-empty-string $streamName
42+
* @param int $jsonDecodeFlags bitmask https://www.php.net/manual/en/function.json-decode.php
43+
* @return \Iterator<int, Rules>
44+
*/
45+
public static function jsonlDecode(
46+
StreamInterface $stream,
47+
string $streamName = 'Stream content',
48+
Exception $exceptionFactory = new UnexpectedValueException(),
49+
bool $allowInvalidJson = false,
50+
int $jsonDecodeFlags = 0,
51+
): \Iterator {
52+
return new class ($stream, $streamName, $exceptionFactory, $allowInvalidJson, $jsonDecodeFlags) implements \Iterator {
53+
private int $lineNumber = 0;
54+
private string $buffer = '';
55+
private ?Rules $current = null;
56+
57+
public function __construct(
58+
private readonly StreamInterface $stream,
59+
private readonly string $streamName,
60+
private readonly Exception $exceptionFactory,
61+
private readonly bool $allowInvalidJson,
62+
private readonly int $jsonDecodeFlags,
63+
) {
64+
$this->fetch();
65+
}
66+
67+
public function key(): ?int
68+
{
69+
if ($this->valid()) {
70+
return $this->lineNumber ?? null;
71+
}
72+
73+
return null;
74+
}
75+
76+
public function current(): Rules
77+
{
78+
if ($this->valid()) {
79+
return $this->current ?? throw $this->exceptionFactory->create($this->streamName . ' value ' . $this->lineNumber . ' is not valid stream');
80+
}
81+
82+
throw $this->exceptionFactory->create($this->streamName . ' value ' . $this->lineNumber . ' is not valid stream');
83+
}
84+
85+
public function next(): void
86+
{
87+
$this->fetch();
88+
}
89+
90+
public function valid(): bool
91+
{
92+
return $this->current !== null || ! $this->stream->eof() || $this->buffer !== '';
93+
}
94+
95+
public function rewind(): void
96+
{
97+
if ($this->lineNumber <= 1) {
98+
return;
99+
}
100+
101+
$this->stream->rewind();
102+
$this->lineNumber = 0;
103+
$this->buffer = '';
104+
$this->fetch();
105+
}
106+
107+
private function fetch(): void
108+
{
109+
if (!$this->valid()) {
110+
return;
111+
}
112+
113+
$this->lineNumber++;
114+
115+
while (!$this->stream->eof()) {
116+
$this->buffer .= $this->stream->read(1024);
117+
$endLine = strpos($this->buffer, "\n");
118+
if ($endLine !== false) {
119+
break;
120+
}
121+
}
122+
123+
if ($this->buffer === '') {
124+
$this->current = null;
125+
return;
126+
}
127+
128+
$endLine = strpos($this->buffer, "\n");
129+
if ($endLine === false) {
130+
$line = $this->buffer;
131+
$this->buffer = '';
132+
} else {
133+
$line = substr($this->buffer, 0, $endLine);
134+
$this->buffer = substr($this->buffer, $endLine + 1);
135+
}
136+
137+
$this->current = JsonService::jsonDecode(
138+
$line,
139+
$this->streamName . ' value ' . $this->lineNumber,
140+
$this->exceptionFactory,
141+
$this->allowInvalidJson,
142+
$this->jsonDecodeFlags,
143+
);
144+
}
145+
};
146+
}
147+
}

0 commit comments

Comments
 (0)