Skip to content

Commit 04b739e

Browse files
authored
Introduce Valinor for type-safe object mapping (#1009)
Introduce Valinor for type-safe object mapping. The first application is mapping a given service account file, JSON, or array to the newly added internal ServiceAccount class, with more to follow in future releases. See https://valinor.cuyz.io/
1 parent 7b579b8 commit 04b739e

14 files changed

Lines changed: 467 additions & 217 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ Please read about the future of the Firebase Admin PHP SDK on the
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
* This release introduces [Valinor](https://valinor.cuyz.io/) for type-safe object mapping. The first application is
13+
mapping a given service account file, JSON, or array to the newly added internal `ServiceAccount` class, with more
14+
to follow in future releases.
15+
1016
## [7.20.0] - 2025-07-18
1117

1218
### Added

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: 85 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
use Beste\Cache\InMemoryCache;
88
use Beste\Clock\SystemClock;
99
use Beste\Clock\WrappingClock;
10-
use Beste\Json;
1110
use Firebase\JWT\CachedKeySet;
1211
use Google\Auth\ApplicationDefaultCredentials;
1312
use Google\Auth\Credentials\ServiceAccountCredentials;
@@ -38,28 +37,22 @@
3837
use Kreait\Firebase\JWT\SessionCookieVerifier;
3938
use Kreait\Firebase\Messaging\AppInstanceApiClient;
4039
use Kreait\Firebase\Messaging\RequestFactory;
40+
use Kreait\Firebase\Valinor\Mapper;
41+
use Kreait\Firebase\Valinor\Normalizer;
42+
use Kreait\Firebase\Valinor\Source;
4143
use Psr\Cache\CacheItemPoolInterface;
4244
use Psr\Clock\ClockInterface;
4345
use Psr\Http\Message\UriInterface;
4446
use Psr\Log\LoggerInterface;
4547
use Psr\Log\LogLevel;
4648
use Stringable;
4749
use Throwable;
48-
use UnexpectedValueException;
4950

5051
use function array_filter;
5152
use function is_string;
5253
use function sprintf;
5354
use function trim;
5455

55-
/**
56-
* @phpstan-type ServiceAccountShape array{
57-
* project_id: non-empty-string,
58-
* client_email: non-empty-string,
59-
* private_key: non-empty-string,
60-
* type: 'service_account'
61-
* }
62-
*/
6356
final class Factory
6457
{
6558
public const API_CLIENT_SCOPES = [
@@ -83,10 +76,7 @@ final class Factory
8376
*/
8477
private ?string $defaultStorageBucket = null;
8578

86-
/**
87-
* @var ServiceAccountShape|null
88-
*/
89-
private ?array $serviceAccount = null;
79+
private ?ServiceAccount $serviceAccount = null;
9080

9181
private ?FetchAuthTokenInterface $googleAuthTokenCredentials = null;
9282

@@ -134,6 +124,14 @@ final class Factory
134124
*/
135125
private array $firestoreClientConfig = [];
136126

127+
private mixed $mapperCache = null;
128+
129+
private mixed $normalizerCache = null;
130+
131+
private ?Mapper $mapper = null;
132+
133+
private ?Normalizer $normalizer = null;
134+
137135
public function __construct()
138136
{
139137
$this->clock = SystemClock::create();
@@ -144,40 +142,16 @@ public function __construct()
144142
}
145143

146144
/**
147-
* @param non-empty-string|ServiceAccountShape $value
145+
* @param string|array<mixed> $value
146+
*
147+
* @throws InvalidArgumentException
148148
*/
149149
public function withServiceAccount(string|array $value): self
150150
{
151-
if (is_string($value) && str_starts_with($value, '{')) {
152-
try {
153-
/** @var ServiceAccountShape $serviceAccount */
154-
$serviceAccount = Json::decode($value, true);
155-
} catch (UnexpectedValueException $e) {
156-
throw new InvalidArgumentException('Invalid service account: '.$e->getMessage(), $e->getCode(), $e);
157-
}
158-
159-
$factory = clone $this;
160-
$factory->serviceAccount = $serviceAccount;
161-
162-
return $factory;
163-
}
164-
165-
if (is_string($value)) {
166-
try {
167-
/** @var ServiceAccountShape $serviceAccount */
168-
$serviceAccount = Json::decodeFile($value, true);
169-
170-
$factory = clone $this;
171-
$factory->serviceAccount = $serviceAccount;
172-
173-
return $factory;
174-
} catch (UnexpectedValueException $e) {
175-
throw new InvalidArgumentException('Invalid service account: '.$e->getMessage(), $e->getCode(), $e);
176-
}
177-
}
151+
$serviceAccount = $this->mapServiceAccount($value);
178152

179153
$factory = clone $this;
180-
$factory->serviceAccount = $value;
154+
$factory->serviceAccount = $serviceAccount;
181155

182156
return $factory;
183157
}
@@ -308,6 +282,24 @@ public function withKeySetCache(CacheItemPoolInterface $cache): self
308282
return $factory;
309283
}
310284

285+
public function withMapperCache(mixed $cache): self
286+
{
287+
$factory = clone $this;
288+
$factory->mapperCache = $cache;
289+
$factory->mapper = null;
290+
291+
return $factory;
292+
}
293+
294+
public function withNormalizerCache(mixed $cache): self
295+
{
296+
$factory = clone $this;
297+
$factory->normalizerCache = $cache;
298+
$factory->normalizer = null;
299+
300+
return $factory;
301+
}
302+
311303
public function withHttpClientOptions(HttpClientOptions $options): self
312304
{
313305
$factory = clone $this;
@@ -385,8 +377,8 @@ public function createAppCheck(): Contract\AppCheck
385377
return new AppCheck(
386378
new AppCheck\ApiClient($http),
387379
new AppCheckTokenGenerator(
388-
$serviceAccount['client_email'],
389-
$serviceAccount['private_key'],
380+
$serviceAccount->clientEmail,
381+
$serviceAccount->privateKey,
390382
$this->clock,
391383
),
392384
new AppCheckTokenVerifier($projectId, $keySet),
@@ -519,16 +511,7 @@ public function createStorage(): Contract\Storage
519511
* @deprecated 7.20.0
520512
* @codeCoverageIgnore
521513
*
522-
* @return array{
523-
* credentialsType: string|null,
524-
* databaseUrl: string,
525-
* defaultStorageBucket: string|null,
526-
* serviceAccount: string|array<string, string>|null,
527-
* projectId: string,
528-
* tenantId: non-empty-string|null,
529-
* tokenCacheType: class-string,
530-
* verifierCacheType: class-string,
531-
* }
514+
* @return array<mixed>
532515
*/
533516
public function getDebugInfo(): array
534517
{
@@ -616,13 +599,7 @@ public function createApiClient(?array $config = null, ?array $middlewares = nul
616599
}
617600

618601
/**
619-
* @return array{
620-
* projectId: non-empty-string,
621-
* authCache: CacheItemPoolInterface,
622-
* credentialsFetcher?: FetchAuthTokenInterface,
623-
* keyFile?: ServiceAccountShape,
624-
* keyFilePath?: non-empty-string
625-
* }
602+
* @return array<non-empty-string, mixed>
626603
*/
627604
private function googleCloudClientConfig(): array
628605
{
@@ -638,7 +615,7 @@ private function googleCloudClientConfig(): array
638615

639616
$serviceAccount = $this->getServiceAccount();
640617
if ($serviceAccount !== null) {
641-
$config['keyFile'] = $serviceAccount;
618+
$config['keyFile'] = $this->normalizeServiceAccount($serviceAccount);
642619
}
643620

644621
return $config;
@@ -658,6 +635,8 @@ private function getProjectId(): string
658635
? $credentials->getProjectId()
659636
: Util::getenv('GOOGLE_CLOUD_PROJECT');
660637

638+
$projectId ??= $this->getServiceAccount()?->projectId;
639+
661640
if (is_string($projectId) && $projectId !== '') {
662641
return $this->projectId = $projectId;
663642
}
@@ -670,23 +649,15 @@ private function getProjectId(): string
670649
*/
671650
private function getDatabaseUrl(): string
672651
{
673-
if ($this->databaseUrl === null) {
674-
$this->databaseUrl = sprintf('https://%s.firebaseio.com', $this->getProjectId());
675-
}
676-
677-
return $this->databaseUrl;
652+
return $this->databaseUrl ??= sprintf('https://%s.firebaseio.com', $this->getProjectId());
678653
}
679654

680655
/**
681656
* @return non-empty-string
682657
*/
683658
private function getStorageBucketName(): string
684659
{
685-
if ($this->defaultStorageBucket === null) {
686-
$this->defaultStorageBucket = sprintf('%s.appspot.com', $this->getProjectId());
687-
}
688-
689-
return $this->defaultStorageBucket;
660+
return $this->defaultStorageBucket ??= sprintf('%s.appspot.com', $this->getProjectId());
690661
}
691662

692663
private function createCustomTokenGenerator(): ?CustomTokenViaGoogleCredentials
@@ -711,46 +682,49 @@ private function createIdTokenVerifier(): IdTokenVerifier
711682
return $verifier->withExpectedTenantId($this->tenantId);
712683
}
713684

714-
private function createSessionCookieVerifier(): SessionCookieVerifier
685+
private function getMapper(): Mapper
715686
{
716-
return SessionCookieVerifier::createWithProjectIdAndCache($this->getProjectId(), $this->verifierCache ?? $this->defaultCache);
687+
return $this->mapper ??= new Mapper($this->mapperCache);
717688
}
718689

719-
/**
720-
* @return ServiceAccountShape|null
721-
*/
722-
private function getServiceAccount(): ?array
690+
private function getNormalizer(): Normalizer
723691
{
724-
if ($this->serviceAccount === null) {
725-
$googleApplicationCredentials = Util::getenv('GOOGLE_APPLICATION_CREDENTIALS');
692+
return $this->normalizer ??= new Normalizer($this->normalizerCache);
693+
}
726694

727-
if ($googleApplicationCredentials === null) {
728-
return null;
729-
}
695+
private function createSessionCookieVerifier(): SessionCookieVerifier
696+
{
697+
return SessionCookieVerifier::createWithProjectIdAndCache($this->getProjectId(), $this->verifierCache ?? $this->defaultCache);
698+
}
730699

731-
if (!str_starts_with($googleApplicationCredentials, '{')) {
732-
return null;
733-
}
700+
private function getServiceAccount(): ?ServiceAccount
701+
{
702+
if ($this->serviceAccount !== null) {
703+
return $this->serviceAccount;
704+
}
734705

735-
/** @var ServiceAccountShape $serviceAccount */
736-
$serviceAccount = Json::decode($googleApplicationCredentials, true);
706+
$googleApplicationCredentials = Util::getenv('GOOGLE_APPLICATION_CREDENTIALS');
737707

738-
$this->serviceAccount = $serviceAccount;
708+
if ($googleApplicationCredentials === null) {
709+
return $this->serviceAccount;
739710
}
740711

741-
return $this->serviceAccount;
712+
return $this->serviceAccount = $this->mapServiceAccount($googleApplicationCredentials);
742713
}
743714

744715
private function getGoogleAuthTokenCredentials(): ?FetchAuthTokenInterface
745716
{
746-
if ($this->googleAuthTokenCredentials !== null) {
717+
if ($this->googleAuthTokenCredentials instanceof FetchAuthTokenInterface) {
747718
return $this->googleAuthTokenCredentials;
748719
}
749720

750721
$serviceAccount = $this->getServiceAccount();
751722

752723
if ($serviceAccount !== null) {
753-
return $this->googleAuthTokenCredentials = new ServiceAccountCredentials(self::API_CLIENT_SCOPES, $serviceAccount);
724+
return $this->googleAuthTokenCredentials = new ServiceAccountCredentials(
725+
self::API_CLIENT_SCOPES,
726+
$this->normalizeServiceAccount($serviceAccount),
727+
);
754728
}
755729

756730
try {
@@ -759,4 +733,22 @@ private function getGoogleAuthTokenCredentials(): ?FetchAuthTokenInterface
759733
return null;
760734
}
761735
}
736+
737+
private function mapServiceAccount(mixed $value): ServiceAccount
738+
{
739+
return $this->getMapper()
740+
->allowSuperfluousKeys()
741+
->snakeToCamelCase()
742+
->map(ServiceAccount::class, Source::parse($value));
743+
}
744+
745+
/**
746+
* @return array<non-empty-string, mixed>
747+
*/
748+
private function normalizeServiceAccount(ServiceAccount $serviceAccount): array
749+
{
750+
return $this->getNormalizer()
751+
->camelToSnakeCase()
752+
->toArray($serviceAccount);
753+
}
762754
}

src/Firebase/ServiceAccount.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kreait\Firebase;
6+
7+
use SensitiveParameter;
8+
9+
/**
10+
* @internal
11+
*/
12+
final class ServiceAccount
13+
{
14+
public function __construct(
15+
/** @var non-empty-string */
16+
public string $type,
17+
/** @var non-empty-string */
18+
#[SensitiveParameter] public string $projectId,
19+
/** @var non-empty-string */
20+
#[SensitiveParameter] public string $clientEmail,
21+
/** @var non-empty-string */
22+
#[SensitiveParameter] public string $clientId,
23+
/** @var non-empty-string */
24+
#[SensitiveParameter] public string $privateKey,
25+
/** @var non-empty-string */
26+
#[SensitiveParameter] public string $privateKeyId,
27+
/** @var non-empty-string */
28+
#[SensitiveParameter] public string $authUri,
29+
/** @var non-empty-string */
30+
#[SensitiveParameter] public string $tokenUri,
31+
/** @var non-empty-string */
32+
#[SensitiveParameter] public string $authProviderX509CertUrl,
33+
/** @var non-empty-string */
34+
#[SensitiveParameter] public string $clientX509CertUrl,
35+
/** @var non-empty-string|null */
36+
#[SensitiveParameter] public ?string $quotaProjectId = null,
37+
/** @var non-empty-string|null */
38+
#[SensitiveParameter] public ?string $universeDomain = null,
39+
) {
40+
}
41+
}

0 commit comments

Comments
 (0)