Skip to content

Commit d756a5a

Browse files
committed
Integrate cuyz/valinor for type-safe object mapping
1 parent 7b579b8 commit d756a5a

8 files changed

Lines changed: 317 additions & 0 deletions

File tree

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"beste/clock": "^3.0",
3232
"beste/in-memory-cache": "^1.3.1",
3333
"beste/json": "^1.5.1",
34+
"cuyz/valinor": "^2.0",
3435
"fig/http-message-util": "^1.1.5",
3536
"firebase/php-jwt": "^6.10.2",
3637
"google/auth": "^v1.45",

phpstan.neon.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ parameters:
2828
reportPossiblyNonexistentConstantArrayOffset: true
2929
treatPhpDocTypesAsCertain: false
3030
includes:
31+
- vendor/cuyz/valinor/qa/PHPStan/valinor-phpstan-configuration.php
3132
- vendor/phpstan/phpstan/conf/bleedingEdge.neon

src/Firebase/Factory.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
use Kreait\Firebase\JWT\SessionCookieVerifier;
3939
use Kreait\Firebase\Messaging\AppInstanceApiClient;
4040
use Kreait\Firebase\Messaging\RequestFactory;
41+
use Kreait\Firebase\Valinor\Mapper;
42+
use Kreait\Firebase\Valinor\Normalizer;
43+
use Kreait\Firebase\Valinor\Source;
4144
use Psr\Cache\CacheItemPoolInterface;
4245
use Psr\Clock\ClockInterface;
4346
use Psr\Http\Message\UriInterface;
@@ -134,6 +137,14 @@ final class Factory
134137
*/
135138
private array $firestoreClientConfig = [];
136139

140+
private mixed $mapperCache = null;
141+
142+
private mixed $normalizerCache = null;
143+
144+
private ?Mapper $mapper = null;
145+
146+
private ?Normalizer $normalizer = null;
147+
137148
public function __construct()
138149
{
139150
$this->clock = SystemClock::create();
@@ -308,6 +319,24 @@ public function withKeySetCache(CacheItemPoolInterface $cache): self
308319
return $factory;
309320
}
310321

322+
public function withMapperCache(mixed $cache): self
323+
{
324+
$factory = clone $this;
325+
$factory->mapperCache = $cache;
326+
$factory->mapper = null;
327+
328+
return $factory;
329+
}
330+
331+
public function withNormalizerCache(mixed $cache): self
332+
{
333+
$factory = clone $this;
334+
$factory->normalizerCache = $cache;
335+
$factory->normalizer = null;
336+
337+
return $factory;
338+
}
339+
311340
public function withHttpClientOptions(HttpClientOptions $options): self
312341
{
313342
$factory = clone $this;
@@ -711,6 +740,16 @@ private function createIdTokenVerifier(): IdTokenVerifier
711740
return $verifier->withExpectedTenantId($this->tenantId);
712741
}
713742

743+
private function getMapper(): Mapper
744+
{
745+
return $this->mapper ??= new Mapper($this->mapperCache);
746+
}
747+
748+
private function getNormalizer(): Normalizer
749+
{
750+
return $this->normalizer ??= new Normalizer($this->normalizerCache);
751+
}
752+
714753
private function createSessionCookieVerifier(): SessionCookieVerifier
715754
{
716755
return SessionCookieVerifier::createWithProjectIdAndCache($this->getProjectId(), $this->verifierCache ?? $this->defaultCache);
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kreait\Firebase\Valinor\Converter;
6+
7+
use Traversable;
8+
9+
/**
10+
* @internal
11+
*
12+
* @see https://valinor.cuyz.io/latest/how-to/convert-input/#converting-keys-format-from-snake_case-to-camelcase
13+
*/
14+
final class SnakeCaseToCamelCaseConverter
15+
{
16+
public function __invoke(mixed $values, callable $next): object
17+
{
18+
if ($values instanceof Traversable) {
19+
$values = iterator_to_array($values);
20+
}
21+
22+
if (!is_array($values)) {
23+
return $next($values);
24+
}
25+
26+
$camelCaseConverted = array_combine(
27+
array_map(
28+
fn($key): string => lcfirst(str_replace('_', '', ucwords((string) $key, '_'))),
29+
array_keys($values),
30+
),
31+
$values,
32+
);
33+
34+
return $next($camelCaseConverted);
35+
}
36+
}

src/Firebase/Valinor/Mapper.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kreait\Firebase\Valinor;
6+
7+
use CuyZ\Valinor\Mapper\MappingError;
8+
use CuyZ\Valinor\MapperBuilder;
9+
use Kreait\Firebase\Exception\InvalidArgumentException;
10+
use Kreait\Firebase\Valinor\Converter\SnakeCaseToCamelCaseConverter;
11+
12+
/**
13+
* @internal
14+
*/
15+
final class Mapper
16+
{
17+
private readonly MapperBuilder $mapperBuilder;
18+
19+
public function __construct(private readonly mixed $cache = null, ?MapperBuilder $builder = null)
20+
{
21+
$builder ??= new MapperBuilder();
22+
23+
if ($cache !== null) {
24+
$builder = $builder->withCache($this->cache);
25+
}
26+
27+
$this->mapperBuilder = $builder;
28+
}
29+
30+
public function withConverter(callable $converter): self
31+
{
32+
$mapperBuilder = $this->mapperBuilder->registerConverter($converter); // @phpstan-ignore-line argument.type
33+
34+
return new self($this->cache, $mapperBuilder);
35+
}
36+
37+
public function snakeToCamelCase(): self
38+
{
39+
return $this->withConverter(new SnakeCaseToCamelCaseConverter());
40+
}
41+
42+
public function allowSuperfluousKeys(): self
43+
{
44+
return new self($this->cache, $this->mapperBuilder->allowSuperfluousKeys());
45+
}
46+
47+
/**
48+
* @template T
49+
* @param class-string<T> $signature
50+
*
51+
* @throws InvalidArgumentException
52+
*
53+
* @return T
54+
*/
55+
public function map(string $signature, mixed $source): mixed
56+
{
57+
try {
58+
return $this->mapperBuilder->mapper()->map($signature, $source);
59+
} catch (MappingError $e) {
60+
$errorMessages = [];
61+
foreach ($e->messages() as $message) {
62+
$errorMessages[] = '- `'.$message->path().'`: '.$message->toString();
63+
}
64+
65+
$message = "Could not map type `$signature`:".PHP_EOL;
66+
$message .= implode(PHP_EOL, $errorMessages);
67+
68+
throw new InvalidArgumentException($message, 0, $e);
69+
}
70+
}
71+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kreait\Firebase\Valinor;
6+
7+
use CuyZ\Valinor\Normalizer\ArrayNormalizer;
8+
use CuyZ\Valinor\Normalizer\Format;
9+
use CuyZ\Valinor\Normalizer\JsonNormalizer;
10+
use CuyZ\Valinor\NormalizerBuilder;
11+
use Kreait\Firebase\Valinor\Transformer\CamelToSnakeCaseTransformer;
12+
13+
/**
14+
* @internal
15+
*/
16+
final class Normalizer
17+
{
18+
private const DEFAULT_JSON_OPTIONS = JSON_UNESCAPED_SLASHES
19+
| JSON_UNESCAPED_UNICODE
20+
| JSON_UNESCAPED_SLASHES
21+
| JSON_UNESCAPED_UNICODE
22+
;
23+
24+
public NormalizerBuilder $normalizerBuilder;
25+
26+
public ?ArrayNormalizer $arrayNormalizer = null;
27+
28+
public ?JsonNormalizer $jsonNormalizer = null;
29+
30+
public function __construct(private readonly mixed $cache = null, ?NormalizerBuilder $builder = null)
31+
{
32+
$builder ??= new NormalizerBuilder();
33+
34+
if ($cache !== null) {
35+
$builder = $builder->withCache($this->cache);
36+
}
37+
38+
$this->normalizerBuilder = $builder;
39+
}
40+
41+
public function withTransformer(callable $transformer): self
42+
{
43+
$builder = $this->normalizerBuilder->registerTransformer($transformer); // @phpstan-ignore-line argument.type
44+
45+
return new self($this->cache, $builder);
46+
}
47+
48+
public function camelToSnakeCase(): self
49+
{
50+
return $this->withTransformer(new CamelToSnakeCaseTransformer());
51+
}
52+
53+
/**
54+
* @return array<mixed>
55+
*/
56+
public function toArray(mixed $value): array
57+
{
58+
$this->arrayNormalizer ??= $this->normalizerBuilder->normalizer(Format::array());
59+
60+
$result = $this->arrayNormalizer->normalize($value);
61+
assert(is_array($result));
62+
63+
return $result;
64+
}
65+
66+
/**
67+
* @param int $options JSON encoding options
68+
*
69+
* @return non-empty-string
70+
*/
71+
public function toJson(mixed $value, ?int $options = null): string
72+
{
73+
$options ??= self::DEFAULT_JSON_OPTIONS;
74+
75+
$this->jsonNormalizer ??= $this->normalizerBuilder->normalizer(Format::json())->withOptions($options);
76+
77+
$result = $this->jsonNormalizer->normalize($value);
78+
assert($result !== '');
79+
80+
return $result;
81+
}
82+
}

src/Firebase/Valinor/Source.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kreait\Firebase\Valinor;
6+
7+
use CuyZ\Valinor\Mapper\Source\Source as BaseSource;
8+
use IteratorAggregate;
9+
use Kreait\Firebase\Exception\InvalidArgumentException;
10+
use SplFileObject;
11+
use Throwable;
12+
use Traversable;
13+
14+
/**
15+
* @internal
16+
*
17+
* @implements IteratorAggregate<mixed>
18+
*/
19+
final class Source implements IteratorAggregate
20+
{
21+
private function __construct(
22+
/** @var iterable<mixed> */
23+
private readonly iterable $delegate,
24+
) {
25+
}
26+
27+
public static function parse(mixed $value): self
28+
{
29+
if (is_iterable($value)) {
30+
return new self(BaseSource::iterable($value));
31+
}
32+
33+
if (str_starts_with((string) $value, '{')) {
34+
try {
35+
return new self(BaseSource::json($value));
36+
} catch (Throwable $e) {
37+
throw new InvalidArgumentException(message: $e->getMessage(), previous: $e);
38+
}
39+
}
40+
41+
try {
42+
return new self(BaseSource::file(new SplFileObject($value)));
43+
} catch (Throwable $e) {
44+
throw new InvalidArgumentException(message: $e->getMessage(), previous: $e);
45+
}
46+
}
47+
48+
public function getIterator(): Traversable
49+
{
50+
yield from $this->delegate;
51+
}
52+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kreait\Firebase\Valinor\Transformer;
6+
7+
/**
8+
* @internal
9+
*
10+
* @see https://valinor.cuyz.io/latest/serialization/common-transformers-examples/#transforming-property-name-to-snake_case
11+
*/
12+
final class CamelToSnakeCaseTransformer
13+
{
14+
public function __invoke(object $object, callable $next): mixed
15+
{
16+
$result = $next();
17+
18+
if (! is_array($result)) {
19+
return $result;
20+
}
21+
22+
$snakeCased = [];
23+
24+
foreach ($result as $key => $value) {
25+
$newKey = preg_replace('/[A-Z]/', '_$0', lcfirst($key));
26+
assert(is_string($newKey));
27+
28+
$newKey = strtolower($newKey);
29+
30+
$snakeCased[$newKey] = $value;
31+
}
32+
33+
return $snakeCased;
34+
}
35+
}

0 commit comments

Comments
 (0)