Skip to content

Commit 2474adf

Browse files
committed
feat(v3): add fluent resource foundation
1 parent 2484ece commit 2474adf

10 files changed

Lines changed: 495 additions & 26 deletions

File tree

‎src/Api.php‎

Lines changed: 130 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@
1212
use ProgrammatorDev\Api\Builder\ClientBuilder;
1313
use ProgrammatorDev\Api\Builder\Listener\CacheLoggerListener;
1414
use ProgrammatorDev\Api\Builder\LoggerBuilder;
15+
use ProgrammatorDev\Api\Builder\ResponseBuilder;
1516
use ProgrammatorDev\Api\Event\PostRequestEvent;
1617
use ProgrammatorDev\Api\Event\PreRequestEvent;
1718
use ProgrammatorDev\Api\Event\ResponseContentsEvent;
1819
use ProgrammatorDev\Api\Helper\StringHelper;
20+
use ProgrammatorDev\Api\Request\RequestOptions;
1921
use Psr\Http\Client\ClientExceptionInterface as ClientException;
2022
use Psr\Http\Message\RequestInterface;
23+
use Psr\Http\Message\ResponseInterface;
2124
use Psr\Http\Message\StreamInterface;
2225
use Symfony\Component\EventDispatcher\EventDispatcher;
2326

@@ -37,11 +40,14 @@ class Api
3740

3841
private ?Authentication $authentication = null;
3942

43+
private ResponseBuilder $responseBuilder;
44+
4045
private EventDispatcher $eventDispatcher;
4146

4247
public function __construct()
4348
{
4449
$this->clientBuilder ??= new ClientBuilder();
50+
$this->responseBuilder = new ResponseBuilder();
4551
$this->eventDispatcher = new EventDispatcher();
4652
}
4753

@@ -53,30 +59,10 @@ public function request(
5359
string $path,
5460
array $query = [],
5561
array $headers = [],
56-
string|StreamInterface $body = null
62+
string|StreamInterface|null $body = null
5763
): mixed
5864
{
59-
$this->configurePlugins();
60-
61-
if (!empty($this->queryDefaults)) {
62-
$query = array_merge($this->queryDefaults, $query);
63-
}
64-
65-
if (!empty($this->headerDefaults)) {
66-
$headers = array_merge($this->headerDefaults, $headers);
67-
}
68-
69-
$url = $this->buildUrl($path, $query);
70-
$request = $this->createRequest($method, $url, $headers, $body);
71-
72-
// pre request listener
73-
$request = $this->eventDispatcher->dispatch(new PreRequestEvent($request))->getRequest();
74-
75-
// request
76-
$response = $this->clientBuilder->getClient()->sendRequest($request);
77-
78-
// post request listener
79-
$response = $this->eventDispatcher->dispatch(new PostRequestEvent($request, $response))->getResponse();
65+
$response = $this->sendRequest($method, $path, $query, $headers, $body);
8066

8167
// always rewind the body contents in case it was used in the PostRequestEvent
8268
// otherwise it would return an empty string
@@ -87,6 +73,73 @@ public function request(
8773
return $this->eventDispatcher->dispatch(new ResponseContentsEvent($contents))->getContents();
8874
}
8975

76+
/**
77+
* @internal
78+
* @throws ClientException
79+
*/
80+
public function send(
81+
string $method,
82+
string $path,
83+
array $pathParams = [],
84+
?RequestOptions $options = null
85+
): Response
86+
{
87+
$options ??= new RequestOptions();
88+
$path = $this->buildPath($path, $pathParams);
89+
90+
$response = $this->sendRequest(
91+
method: $method,
92+
path: $path,
93+
query: $options->getQuery(),
94+
headers: $options->getHeaders()
95+
);
96+
97+
return new Response(
98+
data: $this->getResponseData($response),
99+
rawResponse: $response
100+
);
101+
}
102+
103+
/**
104+
* @template T of Resource
105+
* @param class-string<T> $class
106+
* @return T
107+
*/
108+
protected function resource(string $class): Resource
109+
{
110+
return new $class($this);
111+
}
112+
113+
protected function baseUrl(?string $baseUrl): static
114+
{
115+
$this->setBaseUrl($baseUrl);
116+
117+
return $this;
118+
}
119+
120+
protected function queryDefaults(array $query): static
121+
{
122+
foreach ($query as $name => $value) {
123+
$this->addQueryDefault($name, $value);
124+
}
125+
126+
return $this;
127+
}
128+
129+
protected function headerDefaults(array $headers): static
130+
{
131+
foreach ($headers as $name => $value) {
132+
$this->addHeaderDefault($name, $value);
133+
}
134+
135+
return $this;
136+
}
137+
138+
protected function responses(): ResponseBuilder
139+
{
140+
return $this->responseBuilder;
141+
}
142+
90143
private function configurePlugins(): void
91144
{
92145
// https://docs.php-http.org/en/latest/plugins/content-type.html
@@ -268,7 +321,7 @@ public function buildPath(string $path, array $parameters): string
268321
foreach ($parameters as $parameter => $value) {
269322
$path = str_replace(
270323
sprintf('{%s}', $parameter),
271-
$value,
324+
rawurlencode((string) $value),
272325
$path
273326
);
274327
}
@@ -278,7 +331,8 @@ public function buildPath(string $path, array $parameters): string
278331

279332
private function buildUrl(string $path, array $query = []): string
280333
{
281-
$appendQuery = http_build_query($query);
334+
$query = array_filter($query, static fn(mixed $value): bool => $value !== null);
335+
$appendQuery = http_build_query($query, '', '&', PHP_QUERY_RFC3986);
282336

283337
if (StringHelper::isUrl($path)) {
284338
return append_query_string($path, $appendQuery, APPEND_QUERY_STRING_REPLACE_DUPLICATE);
@@ -292,7 +346,7 @@ private function createRequest(
292346
string $method,
293347
string $url,
294348
array $headers = [],
295-
string|StreamInterface $body = null
349+
string|StreamInterface|null $body = null
296350
): RequestInterface
297351
{
298352
$request = $this->clientBuilder->getRequestFactory()->createRequest($method, $url);
@@ -309,4 +363,54 @@ private function createRequest(
309363

310364
return $request;
311365
}
312-
}
366+
367+
/**
368+
* @throws ClientException
369+
*/
370+
private function sendRequest(
371+
string $method,
372+
string $path,
373+
array $query = [],
374+
array $headers = [],
375+
string|StreamInterface|null $body = null
376+
): ResponseInterface
377+
{
378+
$this->configurePlugins();
379+
380+
if (!empty($this->queryDefaults)) {
381+
$query = array_merge($this->queryDefaults, $query);
382+
}
383+
384+
if (!empty($this->headerDefaults)) {
385+
$headers = array_merge($this->headerDefaults, $headers);
386+
}
387+
388+
$url = $this->buildUrl($path, $query);
389+
$request = $this->createRequest($method, $url, $headers, $body);
390+
391+
// pre request listener
392+
$request = $this->eventDispatcher->dispatch(new PreRequestEvent($request))->getRequest();
393+
394+
// request
395+
$response = $this->clientBuilder->getClient()->sendRequest($request);
396+
397+
// post request listener
398+
return $this->eventDispatcher->dispatch(new PostRequestEvent($request, $response))->getResponse();
399+
}
400+
401+
private function getResponseData(ResponseInterface $response): mixed
402+
{
403+
$response->getBody()->rewind();
404+
$contents = $response->getBody()->getContents();
405+
406+
if (!$this->responseBuilder->shouldDecodeJson()) {
407+
return $contents;
408+
}
409+
410+
if ($contents === '') {
411+
return null;
412+
}
413+
414+
return json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
415+
}
416+
}

‎src/Builder/ResponseBuilder.php‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\Api\Builder;
4+
5+
class ResponseBuilder
6+
{
7+
private bool $decodeJson = false;
8+
9+
public function json(): self
10+
{
11+
$this->decodeJson = true;
12+
13+
return $this;
14+
}
15+
16+
public function shouldDecodeJson(): bool
17+
{
18+
return $this->decodeJson;
19+
}
20+
}

‎src/Entity.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\Api;
4+
5+
interface Entity
6+
{
7+
public static function fromArray(array $data): static;
8+
}

‎src/Request/RequestOptions.php‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\Api\Request;
4+
5+
class RequestOptions
6+
{
7+
public function __construct(
8+
private readonly array $query = [],
9+
private readonly array $headers = []
10+
) {}
11+
12+
public function getQuery(): array
13+
{
14+
return $this->query;
15+
}
16+
17+
public function getHeaders(): array
18+
{
19+
return $this->headers;
20+
}
21+
22+
public function withQuery(string $name, mixed $value): self
23+
{
24+
return $this->withQueries([$name => $value]);
25+
}
26+
27+
public function withQueries(array $query): self
28+
{
29+
return new self(
30+
query: array_merge($this->query, $this->filterNullValues($query)),
31+
headers: $this->headers
32+
);
33+
}
34+
35+
public function withHeader(string $name, mixed $value): self
36+
{
37+
return $this->withHeaders([$name => $value]);
38+
}
39+
40+
public function withHeaders(array $headers): self
41+
{
42+
return new self(
43+
query: $this->query,
44+
headers: array_merge($this->headers, $headers)
45+
);
46+
}
47+
48+
private function filterNullValues(array $values): array
49+
{
50+
return array_filter($values, static fn(mixed $value): bool => $value !== null);
51+
}
52+
}

‎src/Resource.php‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\Api;
4+
5+
use ProgrammatorDev\Api\Request\RequestOptions;
6+
7+
abstract class Resource
8+
{
9+
private RequestOptions $options;
10+
11+
public function __construct(
12+
protected readonly Api $api,
13+
?RequestOptions $options = null
14+
) {
15+
$this->options = $options ?? new RequestOptions();
16+
}
17+
18+
public function query(string $name, mixed $value): static
19+
{
20+
return $this->withOptions($this->options->withQuery($name, $value));
21+
}
22+
23+
public function queries(array $query): static
24+
{
25+
return $this->withOptions($this->options->withQueries($query));
26+
}
27+
28+
public function header(string $name, mixed $value): static
29+
{
30+
return $this->withOptions($this->options->withHeader($name, $value));
31+
}
32+
33+
public function headers(array $headers): static
34+
{
35+
return $this->withOptions($this->options->withHeaders($headers));
36+
}
37+
38+
protected function get(string $path, array $pathParams = [], array $query = []): Response
39+
{
40+
return $this->api->send(
41+
method: Method::GET,
42+
path: $path,
43+
pathParams: $pathParams,
44+
options: $this->options->withQueries($query)
45+
);
46+
}
47+
48+
private function withOptions(RequestOptions $options): static
49+
{
50+
$clone = clone $this;
51+
$clone->options = $options;
52+
53+
return $clone;
54+
}
55+
}

0 commit comments

Comments
 (0)