Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions libs/api-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,44 @@ which the bundle requires directly, so no extra installation is needed.
> If you forget to install appropriate client, you will get exception like
> `Service "Keboola\ApiBundle\Attribute\ApplicationTokenAuth" not found: the container inside "Symfony\Component\DependencyInjection\Argument\ServiceLocator" is a smaller service locator`

## Storage API client

When `#[StorageApiTokenAuth]` is enabled, type-hint
`Keboola\ApiBundle\StorageApiClient\StorageClientApiFactory` on a controller argument; the bundle
injects a factory already bound to the current request and the resolved `StorageApiToken`. Unlike the
header-based `StorageClientRequestFactory`, it uses the token resolved by the authenticator, so it
works for programmatic (`kbc_pat_*` / `kbc_at_*`) tokens too.

The base options are preconfigured by the bundle: the Connection (Storage API) URL from the
`ServiceClient`, the shared logger, and the configured `app_name` as user agent. The run id comes
from the request's `X-KBC-RunId` header; branch / backend come from an optional per-call
`ClientOptions`.

```php
#[StorageApiTokenAuth]
public function __invoke(StorageClientApiFactory $storage)
{
$client = $storage->createClientWrapper()->getBasicClient();

// branch-aware / per-call overrides:
// $wrapper = $storage->createClientWrapper(new ClientOptions(branchId: $branchId));
}
```

On a controller that may authenticate through another attribute too (e.g.
`#[ApplicationTokenAuth]` alongside `#[StorageApiTokenAuth]` — they are OR'd), the request can be
authenticated without a Storage token. Make the argument nullable to receive `null` in that case;
a required (non-nullable) argument throws when no Storage token is present:

```php
#[ApplicationTokenAuth(scopes: ['something:manage'])]
#[StorageApiTokenAuth]
public function __invoke(?StorageClientApiFactory $storage)
{
$client = $storage?->createClientWrapper()->getBasicClient(); // null when authenticated via Manage token
}
```

## Testing controllers

`Keboola\ApiBundle\Test\AuthenticatorTestTrait` stubs the authenticators in functional
Expand Down
20 changes: 20 additions & 0 deletions libs/api-bundle/src/DependencyInjection/KeboolaApiExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,20 @@
use Keboola\ApiBundle\Security\ApplicationToken\ManageApiClientFactory;
use Keboola\ApiBundle\Security\StorageApiToken\StorageApiTokenAuthenticator;
use Keboola\ApiBundle\Security\StorageApiToken\StorageApiTokenFactory;
use Keboola\ApiBundle\StorageApiClient\StorageClientApiFactory;
use Keboola\ApiBundle\StorageApiClient\StorageClientApiFactoryResolver;
use Keboola\ManageApi\Client as ManageApiClient;
use Keboola\ServiceClient\ServiceClient;
use Keboola\ServiceClient\ServiceDnsType;
use Keboola\StorageApiBranch\Factory\ClientOptions;
use Keboola\StorageApiBranch\Factory\StorageClientRequestFactory;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

class KeboolaApiExtension extends Extension
{
Expand Down Expand Up @@ -92,6 +97,21 @@ private function setupStorageApiAuthenticator(
->setArgument('$tokenFactory', new Reference(StorageApiTokenFactory::class))
;
$authenticators[StorageApiTokenAuth::class] = new Reference(StorageApiTokenAuthenticator::class);

// StorageClientApiFactory controller-argument value resolver
$connectionUrl = (new Definition())
->setFactory([new Reference(ServiceClient::class), 'getConnectionServiceUrl']);

$baseClientOptions = (new Definition(ClientOptions::class))
->setArgument('$url', $connectionUrl)
->setArgument('$logger', new Reference('logger'))
->setArgument('$userAgent', $config['app_name']);

$container->register(StorageClientApiFactoryResolver::class)
->setArgument('$baseClientOptions', $baseClientOptions)
->setArgument('$tokenStorage', new Reference(TokenStorageInterface::class))
->addTag('controller.argument_value_resolver')
;
}

private function setupApplicationTokenAuthenticator(
Expand Down
54 changes: 54 additions & 0 deletions libs/api-bundle/src/StorageApiClient/StorageClientApiFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace Keboola\ApiBundle\StorageApiClient;

use Keboola\StorageApiBranch\ClientWrapper;
use Keboola\StorageApiBranch\Factory\AuthType;
use Keboola\StorageApiBranch\Factory\ClientOptions;
use Keboola\StorageApiBranch\StorageApiToken;
use Symfony\Component\HttpFoundation\Request;

class StorageClientApiFactory

@pepamartinec pepamartinec Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming is hard. Nasleduju pattern existujicich factory (StorageClientRequestFactory, StorageClientPlainFactory)

{
public const RUN_ID_HEADER = 'X-KBC-RunId';

public function __construct(
private readonly ClientOptions $baseClientOptions,
private readonly Request $request,
private readonly StorageApiToken $token,
) {
}

public function createClientWrapper(?ClientOptions $clientOptions = null): ClientWrapper
{
$options = clone $this->baseClientOptions;
if ($clientOptions !== null) {
$options->addValuesFrom($clientOptions);
}

$options->setToken($this->token->getTokenValue());
$options->setAuthType(AuthType::STORAGE_TOKEN);
$options->setRunId($this->getRunId($options));

return new ClientWrapper($options);
}

private function getRunId(ClientOptions $options): string
{
$runId = (string) $this->request->headers->get(self::RUN_ID_HEADER);

if ($runId === '') {
$runIdGenerator = $options->getRunIdGenerator();
if ($runIdGenerator !== null) {
$runId = $runIdGenerator($options);
assert(is_string($runId));
} else {
$runId = uniqid('run-');
}
}

return $runId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Keboola\ApiBundle\StorageApiClient;

use Keboola\StorageApiBranch\Factory\ClientOptions;
use Keboola\StorageApiBranch\StorageApiToken;
use RuntimeException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

class StorageClientApiFactoryResolver implements ValueResolverInterface
{
public function __construct(
private readonly ClientOptions $baseClientOptions,
private readonly TokenStorageInterface $tokenStorage,
) {
}

public function resolve(Request $request, ArgumentMetadata $argument): iterable
{
if ($argument->getType() !== StorageClientApiFactory::class) {
return [];
}

$user = $this->tokenStorage->getToken()?->getUser();
if ($user instanceof StorageApiToken) {
return [new StorageClientApiFactory($this->baseClientOptions, $request, $user)];
}

if ($argument->isNullable()) {
return [null];
}

throw new RuntimeException(sprintf(
'Cannot resolve argument "$%s": no authenticated Storage API token. Guard the '
. 'controller with #[StorageApiTokenAuth], or make the argument nullable to allow null.',
$argument->getName(),
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@
use Keboola\ApiBundle\Security\ApplicationToken\ManageApiClientFactory;
use Keboola\ApiBundle\Security\StorageApiToken\StorageApiTokenAuthenticator;
use Keboola\ApiBundle\Security\StorageApiToken\StorageApiTokenFactory;
use Keboola\ApiBundle\StorageApiClient\StorageClientApiFactoryResolver;
use Keboola\ManageApi\Client as ManageApiClient;
use Keboola\ServiceClient\ServiceClient;
use Keboola\StorageApiBranch\Factory\ClientOptions;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

class KeboolaApiExtensionTest extends TestCase
{
Expand Down Expand Up @@ -52,6 +57,44 @@ public function testStorageApiServicesAreRegistered(): void
);
}

public function testStorageClientApiFactoryResolverIsRegisteredWithBaseOptionsAndTagged(): void
{
$container = $this->buildContainer([['app_name' => 'storage-test-app']]);

self::assertTrue(
$container->hasDefinition(StorageClientApiFactoryResolver::class),
'StorageClientApiFactoryResolver must be registered',
);

$definition = $container->getDefinition(StorageClientApiFactoryResolver::class);
self::assertArrayHasKey('controller.argument_value_resolver', $definition->getTags());

$baseClientOptions = $definition->getArgument('$baseClientOptions');
self::assertInstanceOf(Definition::class, $baseClientOptions);
self::assertSame(ClientOptions::class, $baseClientOptions->getClass());

// userAgent is the configured app name
self::assertSame('storage-test-app', $baseClientOptions->getArgument('$userAgent'));

// logger is the shared @logger service
$logger = $baseClientOptions->getArgument('$logger');
self::assertInstanceOf(Reference::class, $logger);
self::assertSame('logger', (string) $logger);

// url is resolved at runtime from ServiceClient::getConnectionServiceUrl()
$url = $baseClientOptions->getArgument('$url');
self::assertInstanceOf(Definition::class, $url);
$urlFactory = $url->getFactory();
self::assertIsArray($urlFactory);
self::assertInstanceOf(Reference::class, $urlFactory[0]);
self::assertSame(ServiceClient::class, (string) $urlFactory[0]);
self::assertSame('getConnectionServiceUrl', $urlFactory[1]);

$tokenStorage = $definition->getArgument('$tokenStorage');
self::assertInstanceOf(Reference::class, $tokenStorage);
self::assertSame(TokenStorageInterface::class, (string) $tokenStorage);
}

// -------------------------------------------------------------------------
// Resolver client wiring
// -------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php

declare(strict_types=1);

namespace Keboola\ApiBundle\Tests\StorageApiClient;

use Keboola\ApiBundle\Security\StorageApiToken\StorageApiToken as SecurityStorageApiToken;
use Keboola\ApiBundle\StorageApiClient\StorageClientApiFactory;
use Keboola\ApiBundle\StorageApiClient\StorageClientApiFactoryResolver;
use Keboola\StorageApiBranch\Factory\ClientOptions;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

class StorageClientApiFactoryResolverTest extends TestCase
{
private function resolver(TokenStorageInterface $tokenStorage): StorageClientApiFactoryResolver
{
return new StorageClientApiFactoryResolver(new ClientOptions('https://connection.test'), $tokenStorage);
}

private function metadataFor(object $controller, string $arg): ArgumentMetadata
{
foreach ((new ArgumentMetadataFactory())->createArgumentMetadata($controller) as $metadata) {
if ($metadata->getName() === $arg) {
return $metadata;
}
}

self::fail(sprintf('Controller has no argument "%s"', $arg));
}

public function testResolvesNothingForOtherArgumentTypes(): void
{
$controller = new class {
public function __invoke(string $foo): void
{
}
};

$tokenStorage = $this->createMock(TokenStorageInterface::class);
$tokenStorage->expects(self::never())->method('getToken');

$result = $this->resolver($tokenStorage)->resolve(new Request(), $this->metadataFor($controller, 'foo'));

self::assertSame([], [...$result]);
}

public function testResolvesBoundFactoryBuildingClientFromSecurityToken(): void
{
$controller = new class {
public function __invoke(StorageClientApiFactory $storage): void
{
}
};

$storageToken = new SecurityStorageApiToken([], 'resolved-token');
$securityToken = $this->createMock(TokenInterface::class);
$securityToken->expects(self::once())->method('getUser')->willReturn($storageToken);
$tokenStorage = $this->createMock(TokenStorageInterface::class);
$tokenStorage->expects(self::once())->method('getToken')->willReturn($securityToken);

$result = [...$this->resolver($tokenStorage)->resolve(
new Request(),
$this->metadataFor($controller, 'storage'),
)];

self::assertCount(1, $result);
self::assertInstanceOf(StorageClientApiFactory::class, $result[0]);
self::assertSame('resolved-token', $result[0]->createClientWrapper()->getClientOptionsReadOnly()->getToken());
}

public function testThrowsForRequiredArgumentWhenNoStorageApiToken(): void
{
$controller = new class {
public function __invoke(StorageClientApiFactory $storage): void
{
}
};

$tokenStorage = $this->createMock(TokenStorageInterface::class);
$tokenStorage->expects(self::once())->method('getToken')->willReturn(null);

$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('#[StorageApiTokenAuth]');

[...$this->resolver($tokenStorage)->resolve(new Request(), $this->metadataFor($controller, 'storage'))];
}

public function testResolvesNullForNullableArgumentWhenNoStorageApiToken(): void
{
// Dual-guarded controller (e.g. also #[ApplicationTokenAuth]) authenticated through the
// other path: the security user is not a StorageApiToken, so a nullable argument gets null.
$controller = new class {
public function __invoke(?StorageClientApiFactory $storage): void
{
}
};

$tokenStorage = $this->createMock(TokenStorageInterface::class);
$tokenStorage->expects(self::once())->method('getToken')->willReturn(null);

$result = [...$this->resolver($tokenStorage)->resolve(
new Request(),
$this->metadataFor($controller, 'storage'),
)];

self::assertSame([null], $result);
}

public function testResolvesFactoryForNullableArgumentWhenStorageApiTokenPresent(): void
{
$controller = new class {
public function __invoke(?StorageClientApiFactory $storage): void
{
}
};

$storageToken = new SecurityStorageApiToken([], 'resolved-token');
$securityToken = $this->createMock(TokenInterface::class);
$securityToken->expects(self::once())->method('getUser')->willReturn($storageToken);
$tokenStorage = $this->createMock(TokenStorageInterface::class);
$tokenStorage->expects(self::once())->method('getToken')->willReturn($securityToken);

$result = [...$this->resolver($tokenStorage)->resolve(
new Request(),
$this->metadataFor($controller, 'storage'),
)];

self::assertCount(1, $result);
self::assertInstanceOf(StorageClientApiFactory::class, $result[0]);
}
}
Loading
Loading