From 680fc5451b1fbfef827b2046a12c1bffeda89c34 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Tue, 7 Apr 2026 11:34:24 +0200 Subject: [PATCH 1/2] feature: flow-php/symfony-filesystem-bundle feature: flow filesystem bundle skeleton feature: bundle configuration tree feature: filesystem factory contracts feature: built in filesystem factories feature: filesystem commands --- .codecov.yaml | 4 + .github/workflows/monorepo-split.yml | 2 + composer.json | 8 + .../bridges/symfony-filesystem-bundle.md | 502 ++++++++++++++++++ .../packages/symfony-filesystem-bundle.md | 39 ++ manifest.json | 5 + phpdoc/bridge.symfony.filesystem.xml | 24 + phpstan.neon | 3 + phpunit.xml.dist | 6 + rector.src.php | 1 + rector.tests.php | 6 +- .../symfony/filesystem-bundle/.gitattributes | 9 + .../.github/workflows/readonly.yaml | 17 + .../symfony/filesystem-bundle/CONTRIBUTING.md | 6 + src/bridge/symfony/filesystem-bundle/LICENSE | 19 + .../symfony/filesystem-bundle/README.md | 14 + .../symfony/filesystem-bundle/composer.json | 48 ++ .../Attribute/AsFilesystemFactory.php | 13 + .../FilesystemBundle/Command/CatCommand.php | 71 +++ .../FilesystemBundle/Command/CpCommand.php | 98 ++++ .../Command/FstabResolver.php | 90 ++++ .../FilesystemBundle/Command/LsCommand.php | 105 ++++ .../FilesystemBundle/Command/MvCommand.php | 61 +++ .../FilesystemBundle/Command/RmCommand.php | 69 +++ .../FilesystemBundle/Command/StatCommand.php | 91 ++++ .../FilesystemBundle/Command/TouchCommand.php | 63 +++ .../Compiler/BuildFstabsPass.php | 149 ++++++ .../RegisterFilesystemFactoriesPass.php | 54 ++ .../Compiler/RegisterFstabLocatorPass.php | 40 ++ .../FilesystemBundle/Exception/Exception.php | 9 + .../Exception/InvalidArgumentException.php | 9 + .../Exception/LogicException.php | 9 + .../Factory/AsyncAwsS3FilesystemFactory.php | 175 ++++++ .../Factory/AzureBlobFilesystemFactory.php | 203 +++++++ .../Factory/MemoryFilesystemFactory.php | 39 ++ .../Factory/NativeLocalFilesystemFactory.php | 39 ++ .../Factory/StdoutFilesystemFactory.php | 39 ++ .../Filesystem/FilesystemFactory.php | 17 + .../Filesystem/FilesystemFactoryRegistry.php | 56 ++ .../Filesystem/FstabBuilder.php | 47 ++ .../FilesystemBundle/FlowFilesystemBundle.php | 167 ++++++ .../Resources/config/services.php | 72 +++ .../Tests/Context/BuildFstabsPassContext.php | 35 ++ .../Tests/Context/CliCommandContext.php | 103 ++++ .../Tests/Context/ConfigurationContext.php | 47 ++ .../Tests/Context/SymfonyContext.php | 88 +++ .../Double/CapturingFilesystemFactory.php | 32 ++ .../Double/MemoryStubFilesystemFactory.php | 26 + .../Tests/Double/StubFilesystemFactory.php | 31 ++ .../Tests/Double/TelemetryStubFactory.php | 16 + .../Tests/Fixtures/TestKernel.php | 136 +++++ .../FilesystemCommandsIntegrationTest.php | 169 ++++++ .../Integration/FlowFilesystemBundleTest.php | 34 ++ .../FstabServiceRegistrationTest.php | 113 ++++ .../Tests/Integration/KernelTestCase.php | 48 ++ .../Attribute/AsFilesystemFactoryTest.php | 27 + .../Tests/Unit/Command/CatCommandTest.php | 59 ++ .../Tests/Unit/Command/CpCommandTest.php | 71 +++ .../Tests/Unit/Command/FstabResolverTest.php | 79 +++ .../Tests/Unit/Command/LsCommandTest.php | 104 ++++ .../Tests/Unit/Command/MvCommandTest.php | 51 ++ .../Tests/Unit/Command/RmCommandTest.php | 74 +++ .../Tests/Unit/Command/StatCommandTest.php | 88 +++ .../Tests/Unit/Command/TouchCommandTest.php | 72 +++ .../Tests/Unit/ConfigurationTest.php | 286 ++++++++++ .../Compiler/BuildFstabsPassTelemetryTest.php | 127 +++++ .../Compiler/BuildFstabsPassTest.php | 223 ++++++++ .../RegisterFilesystemFactoriesPassTest.php | 93 ++++ .../Compiler/RegisterFstabLocatorPassTest.php | 73 +++ .../AsyncAwsS3FilesystemFactoryTest.php | 185 +++++++ .../AzureBlobFilesystemFactoryTest.php | 253 +++++++++ .../Factory/MemoryFilesystemFactoryTest.php | 41 ++ .../NativeLocalFilesystemFactoryTest.php | 41 ++ .../Factory/StdoutFilesystemFactoryTest.php | 41 ++ .../FilesystemFactoryRegistryTest.php | 78 +++ .../Filesystem/FstabBuilderTelemetryTest.php | 41 ++ .../Unit/Filesystem/FstabBuilderTest.php | 95 ++++ .../Tests/Unit/FlowFilesystemBundleTest.php | 63 +++ .../documentation/navigation_right.html.twig | 3 + 79 files changed, 5643 insertions(+), 1 deletion(-) create mode 100644 documentation/components/bridges/symfony-filesystem-bundle.md create mode 100644 documentation/installation/packages/symfony-filesystem-bundle.md create mode 100644 phpdoc/bridge.symfony.filesystem.xml create mode 100644 src/bridge/symfony/filesystem-bundle/.gitattributes create mode 100644 src/bridge/symfony/filesystem-bundle/.github/workflows/readonly.yaml create mode 100644 src/bridge/symfony/filesystem-bundle/CONTRIBUTING.md create mode 100644 src/bridge/symfony/filesystem-bundle/LICENSE create mode 100644 src/bridge/symfony/filesystem-bundle/README.md create mode 100644 src/bridge/symfony/filesystem-bundle/composer.json create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Attribute/AsFilesystemFactory.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/CatCommand.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/CpCommand.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/FstabResolver.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/LsCommand.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/MvCommand.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/RmCommand.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/StatCommand.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/TouchCommand.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/DependencyInjection/Compiler/BuildFstabsPass.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/DependencyInjection/Compiler/RegisterFilesystemFactoriesPass.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/DependencyInjection/Compiler/RegisterFstabLocatorPass.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Exception/Exception.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Exception/InvalidArgumentException.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Exception/LogicException.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/AsyncAwsS3FilesystemFactory.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/AzureBlobFilesystemFactory.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/MemoryFilesystemFactory.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/NativeLocalFilesystemFactory.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/StdoutFilesystemFactory.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/FilesystemFactory.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/FilesystemFactoryRegistry.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/FstabBuilder.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/FlowFilesystemBundle.php create mode 100644 src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Resources/config/services.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/BuildFstabsPassContext.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/CliCommandContext.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/ConfigurationContext.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/SymfonyContext.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/CapturingFilesystemFactory.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/MemoryStubFilesystemFactory.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/StubFilesystemFactory.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/TelemetryStubFactory.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Fixtures/TestKernel.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/Command/FilesystemCommandsIntegrationTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/FlowFilesystemBundleTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/FstabServiceRegistrationTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/KernelTestCase.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Attribute/AsFilesystemFactoryTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/CatCommandTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/CpCommandTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/FstabResolverTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/LsCommandTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/MvCommandTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/RmCommandTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/StatCommandTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/TouchCommandTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/ConfigurationTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/BuildFstabsPassTelemetryTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/BuildFstabsPassTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/RegisterFilesystemFactoriesPassTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/RegisterFstabLocatorPassTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/AsyncAwsS3FilesystemFactoryTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/AzureBlobFilesystemFactoryTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/MemoryFilesystemFactoryTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/NativeLocalFilesystemFactoryTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/StdoutFilesystemFactoryTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/FilesystemFactoryRegistryTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/FstabBuilderTelemetryTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/FstabBuilderTest.php create mode 100644 src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/FlowFilesystemBundleTest.php diff --git a/.codecov.yaml b/.codecov.yaml index 9ce617cfa6..abec5b0c95 100644 --- a/.codecov.yaml +++ b/.codecov.yaml @@ -100,6 +100,10 @@ component_management: name: bridge-symfony-http-foundation-telemetry paths: - src/bridge/symfony/http-foundation-telemetry/** + - component_id: bridge-symfony-filesystem-bundle + name: bridge-symfony-filesystem-bundle + paths: + - src/bridge/symfony/filesystem-bundle/** - component_id: bridge-symfony-postgresql-bundle name: bridge-symfony-postgresql-bundle paths: diff --git a/.github/workflows/monorepo-split.yml b/.github/workflows/monorepo-split.yml index 251bbf9e41..13200ec21d 100644 --- a/.github/workflows/monorepo-split.yml +++ b/.github/workflows/monorepo-split.yml @@ -90,6 +90,8 @@ jobs: split_repository: 'psr7-telemetry-bridge' - local_path: 'src/bridge/psr18/telemetry' split_repository: 'psr18-telemetry-bridge' + - local_path: 'src/bridge/symfony/filesystem-bundle' + split_repository: 'symfony-filesystem-bundle' - local_path: 'src/bridge/symfony/http-foundation' split_repository: 'symfony-http-foundation-bridge' - local_path: 'src/bridge/symfony/http-foundation-telemetry' diff --git a/composer.json b/composer.json index 407f416a5a..67d936d963 100644 --- a/composer.json +++ b/composer.json @@ -107,6 +107,7 @@ "flow-php/psr7-telemetry-bridge": "self.version", "flow-php/psr18-telemetry-bridge": "self.version", "flow-php/snappy": "self.version", + "flow-php/symfony-filesystem-bundle": "self.version", "flow-php/symfony-http-foundation-bridge": "self.version", "flow-php/symfony-http-foundation-telemetry-bridge": "self.version", "flow-php/symfony-postgresql-bundle": "self.version", @@ -143,6 +144,7 @@ "src/bridge/openapi/specification/src/Flow", "src/bridge/psr7/telemetry/src/Flow", "src/bridge/psr18/telemetry/src/Flow", + "src/bridge/symfony/filesystem-bundle/src/Flow", "src/bridge/symfony/http-foundation-telemetry/src/Flow", "src/bridge/symfony/http-foundation/src/Flow", "src/bridge/symfony/postgresql-bundle/src/Flow", @@ -242,6 +244,7 @@ "src/bridge/openapi/specification/tests/Flow", "src/bridge/psr7/telemetry/tests/Flow", "src/bridge/psr18/telemetry/tests/Flow", + "src/bridge/symfony/filesystem-bundle/tests/Flow", "src/bridge/symfony/http-foundation-telemetry/tests/Flow", "src/bridge/symfony/http-foundation/tests/Flow", "src/bridge/symfony/postgresql-bundle/tests/Flow", @@ -318,6 +321,7 @@ "@test:bridge:phpunit-telemetry", "@test:bridge:psr7-telemetry", "@test:bridge:psr18-telemetry", + "@test:bridge:symfony-filesystem-bundle", "@test:bridge:symfony-http-foundation", "@test:bridge:symfony-http-foundation-telemetry", "@test:bridge:symfony-postgresql-bundle", @@ -410,6 +414,10 @@ "tools/phpunit/vendor/bin/phpunit --testsuite=bridge-psr18-telemetry-unit --log-junit ./var/phpunit/logs/bridge-psr18-telemetry-unit.junit.xml --coverage-clover=./var/phpunit/coverage/clover/bridge-psr18-telemetry-unit.coverage.xml", "tools/phpunit/vendor/bin/phpunit --testsuite=bridge-psr18-telemetry-integration --log-junit ./var/phpunit/logs/bridge-psr18-telemetry-integration.junit.xml --coverage-clover=./var/phpunit/coverage/clover/bridge-psr18-telemetry-integration.coverage.xml" ], + "test:bridge:symfony-filesystem-bundle": [ + "tools/phpunit/vendor/bin/phpunit --testsuite=bridge-symfony-filesystem-bundle-unit --log-junit ./var/phpunit/logs/bridge-symfony-filesystem-bundle-unit.junit.xml --coverage-clover=./var/phpunit/coverage/clover/bridge-symfony-filesystem-bundle-unit.coverage.xml", + "tools/phpunit/vendor/bin/phpunit --testsuite=bridge-symfony-filesystem-bundle-integration --log-junit ./var/phpunit/logs/bridge-symfony-filesystem-bundle-integration.junit.xml --coverage-clover=./var/phpunit/coverage/clover/bridge-symfony-filesystem-bundle-integration.coverage.xml" + ], "test:bridge:symfony-http-foundation": [ "tools/phpunit/vendor/bin/phpunit --testsuite=bridge-symfony-http-foundation-unit --log-junit ./var/phpunit/logs/bridge-symfony-http-foundation-unit.junit.xml --coverage-clover=./var/phpunit/coverage/clover/bridge-symfony-http-foundation-unit.coverage.xml", "tools/phpunit/vendor/bin/phpunit --testsuite=bridge-symfony-http-foundation-integration --log-junit ./var/phpunit/logs/bridge-symfony-http-foundation-integration.junit.xml --coverage-clover=./var/phpunit/coverage/clover/bridge-symfony-http-foundation-integration.coverage.xml" diff --git a/documentation/components/bridges/symfony-filesystem-bundle.md b/documentation/components/bridges/symfony-filesystem-bundle.md new file mode 100644 index 0000000000..a99bae147d --- /dev/null +++ b/documentation/components/bridges/symfony-filesystem-bundle.md @@ -0,0 +1,502 @@ +# Symfony Filesystem Bundle + +Symfony bundle integrating Flow PHP's Filesystem library with Symfony applications, providing a typed +`FilesystemTable` service that mounts multiple filesystems under URI protocols, pluggable factories, +console commands for managing files across mounted protocols, and first-class OpenTelemetry integration. + +- [Back](/documentation/introduction.md) +- [Packagist](https://packagist.org/packages/flow-php/symfony-filesystem-bundle) +- [Installation](/documentation/installation/packages/symfony-filesystem-bundle.md) +- [GitHub](https://github.com/flow-php/symfony-filesystem-bundle) + +[TOC] + +## Installation + +For detailed installation instructions, see the [installation page](/documentation/installation/packages/symfony-filesystem-bundle.md). + +Register the bundle in `config/bundles.php`: + +```php +return [ + // ... + Flow\Bridge\Symfony\FilesystemBundle\FlowFilesystemBundle::class => ['all' => true], +]; +``` + +## Overview + +This bundle integrates Flow PHP's Filesystem library with Symfony applications. It provides: + +- **Protocol-keyed routing** — mount filesystems under URI protocols (`file://`, `memory://`, `aws-s3://`, `azure-blob://`, …) and resolve them at runtime via `$table->for(...)` +- **Pluggable filesystem factories** — register custom protocols with the `#[AsFilesystemFactory]` attribute or a DI tag +- **Built-in factories** — `file`, `memory`, `stdout`, `aws-s3`, and `azure-blob` ship out of the box +- **Console commands** — `flow:filesystem:*` (alias `flow:fs:*`) for `ls`, `cat`, `cp`, `mv`, `rm`, `stat`, `touch` against any configured filesystem +- **Telemetry integration** — wrap every filesystem in `TraceableFilesystem` via OpenTelemetry +- **Multi-fstab support** *(advanced)* — configure several independent `FilesystemTable` services when you really need them + +## Quick Start + +A minimal configuration mounts one or more filesystems inside a single fstab. The fstab name is arbitrary — +the bundle auto-detects the only fstab and exposes it as `Flow\Filesystem\FilesystemTable`: + +```yaml +# config/packages/flow_filesystem.yaml +flow_filesystem: + fstabs: + default: + filesystems: + file: ~ + memory: ~ +``` + +Inject the table anywhere via autowiring: + +```php +use Flow\Filesystem\FilesystemTable; +use Flow\Filesystem\Protocol; + +final class ReportBuilder +{ + public function __construct(private readonly FilesystemTable $fstab) + { + } + + public function read(string $uri) : string + { + return $this->fstab->for(new Protocol('file'))->readFrom(/* path */)->content(); + } +} +``` + +> Most applications need exactly one fstab with multiple filesystems mounted under it. Multi-fstab support +> exists for advanced cases — see [Multi-Fstab Support](#multi-fstab-support). + +## Configuration Reference + +### Fstabs + +At least one fstab is required. Each fstab defines a set of filesystems keyed by protocol. The YAML key +under `filesystems:` is the protocol the filesystem will be mounted under. + +```yaml +flow_filesystem: + fstabs: + default: + filesystems: + file: ~ + memory: ~ + aws-s3: + bucket: '%env(S3_BUCKET)%' +``` + +The fstab is wired as a private `.flow_filesystem.fstab.` service and aliased to +`Flow\Filesystem\FilesystemTable` for autowiring. + +### Default Fstab + +When you only have one fstab, the bundle picks it as the default automatically — no `default_fstab` key +needed, and the fstab name does not have to be `default`. Set `default_fstab` explicitly only when you +have multiple fstabs and the one you want as the default is not literally named `default`. + +### Telemetry + +Enable telemetry per fstab to wrap every mounted filesystem with `Flow\Filesystem\Telemetry\TraceableFilesystem`. +You must wire a `Flow\Telemetry\Telemetry` service and a `Psr\Clock\ClockInterface` service yourself — +the bundle does NOT provide defaults. + +```yaml +flow_filesystem: + fstabs: + default: + telemetry: + enabled: true + telemetry_service_id: app.flow_telemetry # Required: Telemetry service ID + clock_service_id: app.system_clock # Required: PSR-20 clock service + options: + trace_streams: true # Record stream open/close in traces + collect_metrics: true # Collect read/write metrics + filesystems: + file: ~ + memory: ~ +``` + +With `telemetry.enabled: false` (the default) the fstab is wired without any telemetry overhead. + +## Built-in Filesystems + +The bundle ships with factories for these protocols out of the box: + +| Protocol | Options | Notes | +|----------|---------|-------| +| `file` | none | Local filesystem via native PHP calls. | +| `memory` | none | In-memory filesystem, lifetime of the kernel. | +| `stdout` | none | Write-only sink that targets STDOUT. | +| `aws-s3` | `bucket`, `client*`, `options` | Requires `flow-php/filesystem-async-aws-bridge`. | +| `azure-blob` | `container`, `client*`, `options` | Requires `flow-php/filesystem-azure-bridge`. | + +The protocol used as a YAML key is matched against a registered `FilesystemFactory` via that factory's +`protocol()` value. To mount a custom protocol, register your own factory (see [Custom Filesystem Factory](#custom-filesystem-factory)). + +### AWS S3 + +Install the bridge: + +```bash +composer require flow-php/filesystem-async-aws-bridge +``` + +**Mode A — bring your own `AsyncAws\S3\S3Client`:** + +```yaml +flow_filesystem: + fstabs: + default: + filesystems: + aws-s3: + bucket: my-bucket + client_service_id: app.async_aws_s3_client +``` + +**Mode B — inline client config:** + +```yaml +flow_filesystem: + fstabs: + default: + filesystems: + aws-s3: + bucket: my-bucket + client: + region: '%env(AWS_REGION)%' + access_key_id: '%env(AWS_ACCESS_KEY_ID)%' + access_key_secret: '%env(AWS_SECRET_ACCESS_KEY)%' + endpoint: ~ # optional, MinIO/R2/Scaleway/LocalStack + path_style_endpoint: false + profile: ~ + debug: false + http_client_service_id: ~ + logger_service_id: ~ + options: + block_size: 6291456 +``` + +Any null `client.*` key is omitted from the underlying AsyncAws resolution chain, so environment-based +credential discovery still works. + +### Azure Blob Storage + +Install the bridge: + +```bash +composer require flow-php/filesystem-azure-bridge +``` + +**Mode A — bring your own `Flow\Azure\SDK\BlobServiceInterface`:** + +```yaml +flow_filesystem: + fstabs: + default: + filesystems: + azure-blob: + container: my-container + client_service_id: app.azure_blob_service +``` + +**Mode B — inline client config (shared key auth):** + +```yaml +flow_filesystem: + fstabs: + default: + filesystems: + azure-blob: + container: my-container + client: + account_name: '%env(AZURE_ACCOUNT_NAME)%' + auth: + shared_key: '%env(AZURE_ACCOUNT_KEY)%' + url_factory: # optional, e.g. Azurite + host: 127.0.0.1 + port: '10000' + https: false + http_client_service: ~ # optional PSR-18 client + request_factory_service: ~ # optional PSR-17 request factory + stream_factory_service: ~ # optional PSR-17 stream factory + logger_service_id: ~ # optional PSR-3 logger + options: + block_size: 4194304 + list_blob_max_results: 100 +``` + +SAS token auth is not supported yet. + +## Console Commands + +The bundle ships a small set of `flow:filesystem:*` console commands that operate on any mounted +filesystem. Unlike `league/flysystem-bundle`, which does not provide any CLI tools, these commands are a +Flow-specific differentiator: you can inspect, read, write, copy, move, and delete files across mounted +protocols straight from `bin/console`. + +Each command also has a shorter `flow:fs:*` alias (e.g. `flow:fs:ls`). + +### Filesystem Commands + +| Command | Description | +|---------|-------------| +| `flow:filesystem:ls` | List entries under a directory URI | +| `flow:filesystem:cat` | Stream a file URI to STDOUT | +| `flow:filesystem:cp` | Copy a file between two URIs | +| `flow:filesystem:mv` | Move a file between two URIs (copy + delete source) | +| `flow:filesystem:rm` | Remove a file or directory | +| `flow:filesystem:stat` | Print metadata about a file or directory URI | +| `flow:filesystem:touch` | Create an empty file at a URI | + +**Common options:** + +| Option | Description | +|--------|-------------| +| `--fstab` (`-f`) | *(advanced)* Target a specific fstab when more than one is configured; defaults to the bundle default fstab | + +**Per-command options:** + +| Command | Options | +|---------|---------| +| `ls` | `--recursive` (`-r`), `--long` (`-l`), `--format=json` | +| `rm` | `--recursive` (`-r`) — required for directories | +| `stat` | `--format=json` | +| `touch` | `--force` (`-F`) — overwrite existing file with empty content | + +### URI Format + +Path arguments are full URIs in the form `://`. When a protocol is omitted, the bundle +assumes the local filesystem (`file://`) and resolves relative paths against the current working directory: + +```bash +bin/console flow:filesystem:ls ./reports # → file:////reports +bin/console flow:filesystem:ls /var/log # → file:///var/log +bin/console flow:filesystem:ls memory://reports # explicit protocol +``` + +### Examples + +```bash +bin/console flow:filesystem:ls memory://reports --long +bin/console flow:filesystem:cat file:///tmp/dump.csv +bin/console flow:filesystem:cp memory://staging/out.parquet file:///var/lib/flow/out.parquet +bin/console flow:filesystem:mv memory://tmp/result.json aws-s3://bucket/result.json +bin/console flow:filesystem:rm file:///var/cache/flow --recursive +bin/console flow:filesystem:stat aws-s3://bucket/key.bin --format=json +bin/console flow:filesystem:touch file:///tmp/.flow_smoke_test +``` + +### `cp` / `mv` work across protocols, not across fstabs + +`cp` and `mv` happily copy between any two protocols mounted in the same fstab — e.g. +`memory://… → aws-s3://…` or `file:///… → azure-blob://…`. What they do **not** do is bridge two +separate fstabs: source and destination must resolve through the same `FilesystemTable`. Since most apps +use a single fstab with everything mounted under it, this is rarely a real constraint. + +If you genuinely need to move data between two fstabs, run two commands and stage through a temporary +location: + +```bash +bin/console flow:filesystem:cp aws-s3://bucket/file.csv file:///tmp/file.csv --fstab=warehouse +bin/console flow:filesystem:cp file:///tmp/file.csv azure-blob://container/file.csv --fstab=archive +``` + +### `mkdir` is intentionally absent + +Remote object stores (S3, Azure Blob, …) do not have a real concept of directories — they expose flat +keyspaces with `/` as a convention. Rather than emulate `mkdir` inconsistently across backends, the bundle +omits the command entirely. Directories appear when files appear inside them. + +## Multi-Fstab Support + +> **Advanced.** Most applications should stick to a single fstab with multiple filesystems mounted under +> it. Reach for multi-fstab only when you need fully isolated tables — e.g. the same protocol mounted +> against two different backends (two S3 buckets, two Azure containers), or strict separation between +> read-only archive storage and a read/write working area. + +The bundle supports multiple independent fstabs, each with its own protocol keyspace: + +```yaml +flow_filesystem: + default_fstab: primary + fstabs: + primary: + filesystems: + file: ~ + + analytics: + filesystems: + memory: ~ + file: ~ + + archive: + filesystems: + aws-s3: + bucket: '%env(ARCHIVE_BUCKET)%' +``` + +Each fstab gets its own `.flow_filesystem.fstab.` service. The default fstab is automatically aliased +to `Flow\Filesystem\FilesystemTable`, allowing direct type-hint injection without specifying a fstab name. +Each named fstab is also aliased as `Flow\Filesystem\FilesystemTable $Fstab` for +named-argument autowiring: + +```php +use Flow\Filesystem\FilesystemTable; +use Flow\Filesystem\Protocol; + +final class ReportBuilder +{ + public function __construct( + private readonly FilesystemTable $fstab, // → primary (default) + private readonly FilesystemTable $analyticsFstab, // → analytics + private readonly FilesystemTable $archiveFstab, // → archive + ) { + } + + public function read() : string + { + return $this->fstab->for(new Protocol('file'))->readFrom(/* path */)->content(); + } +} +``` + +Target a specific fstab with any console command: + +```bash +bin/console flow:filesystem:ls aws-s3://bucket/reports --fstab=archive +``` + +## Custom Filesystem Factory + +The bundle has no concept of "custom" filesystems — every filesystem is created by a `FilesystemFactory`. +To plug in your own protocol, implement +`Flow\Bridge\Symfony\FilesystemBundle\Filesystem\FilesystemFactory`: + +```php +namespace App\Flow; + +use Flow\Bridge\Symfony\FilesystemBundle\Filesystem\FilesystemFactory; +use Flow\Filesystem\{Filesystem, Protocol}; + +final class MyFilesystemFactory implements FilesystemFactory +{ + public function protocol() : Protocol + { + return new Protocol('my-fs'); + } + + public function create(Protocol $protocol, array $config) : Filesystem + { + // build and return the Filesystem; $config is the array under + // filesystems. in YAML. + } +} +``` + +There are two ways to register the factory. + +### Attribute-Based Discovery + +Annotate the factory class with `#[AsFilesystemFactory]` for automatic discovery: + +```php +namespace App\Flow; + +use Flow\Bridge\Symfony\FilesystemBundle\Attribute\AsFilesystemFactory; +use Flow\Bridge\Symfony\FilesystemBundle\Filesystem\FilesystemFactory; +use Flow\Filesystem\{Filesystem, Protocol}; + +#[AsFilesystemFactory(protocol: 'my-fs')] +final class MyFilesystemFactory implements FilesystemFactory +{ + public function protocol() : Protocol { return new Protocol('my-fs'); } + public function create(Protocol $protocol, array $config) : Filesystem { /* ... */ } +} +``` + +As long as your service is autoconfigured (the default in `services.yaml` for everything under your `App\` +namespace), the bundle automatically attaches the `flow_filesystem.factory` tag with the right `protocol` +attribute. + +### Explicit Tag + +Useful if your factory lives in a service that doesn't have autoconfigure enabled (e.g. third-party +namespaces, manual definitions): + +```yaml +services: + app.flow_filesystem.factory.my_fs: + class: App\Flow\MyFilesystemFactory + tags: + - { name: flow_filesystem.factory, protocol: my-fs } +``` + +Either way, you can then mount the protocol in any fstab: + +```yaml +flow_filesystem: + fstabs: + default: + filesystems: + my-fs: + foo: bar # arbitrary options handed to the factory's create() +``` + +**Notes:** + +- The `protocol` attribute on the tag (or on `#[AsFilesystemFactory]`) must match `protocol()->name` exactly. + The bundle does not enforce this at compile time, but the registry will fail at boot if mismatched. +- A protocol can be served by exactly **one** factory; registering two factories for the same protocol + fails at compile time. +- Within a single fstab, a protocol can be mounted exactly **once** — this is enforced by `FilesystemTable` + itself. To use the same backend twice (e.g. two S3 buckets), put each one in its own fstab. + +## Comparison with `league/flysystem-bundle` + +- **Multi-fstab:** each fstab is an independent `FilesystemTable` service with its own protocol keyspace. + Flysystem Bundle wires individual filesystems keyed only by name. +- **Protocol-keyed routing:** filesystems inside a fstab are resolved at runtime via + `$table->for(new Protocol('...'))`, so application code can hand-off across protocols without knowing + service ids. +- **Factory tag:** filesystem protocols are pluggable via a standard `flow_filesystem.factory` DI tag; + third-party libraries can ship their own factory without bundle changes. +- **CLI commands:** `flow:filesystem:*` ship with the bundle and operate on any configured fstab. + Flysystem Bundle does not ship any console commands. +- **Telemetry first-class:** OTel `TraceableFilesystem` is wired by simply toggling + `telemetry.enabled: true` per fstab. +- **No autoconfigure magic:** every tagged service and compiler-pass action is explicit, matching the + Symfony 6.4+/7.4+/8.0+ bundle best practices. + +## Complete Example + +A typical single-fstab setup with local + S3 mounts and telemetry enabled: + +```yaml +# config/packages/flow_filesystem.yaml +flow_filesystem: + fstabs: + default: + telemetry: + enabled: true + telemetry_service_id: app.flow_telemetry + clock_service_id: app.system_clock + options: + trace_streams: true + collect_metrics: true + filesystems: + file: ~ + memory: ~ + aws-s3: + bucket: '%env(S3_BUCKET)%' + client: + region: '%env(AWS_REGION)%' + access_key_id: '%env(AWS_ACCESS_KEY_ID)%' + access_key_secret: '%env(AWS_SECRET_ACCESS_KEY)%' + options: + block_size: 6291456 +``` diff --git a/documentation/installation/packages/symfony-filesystem-bundle.md b/documentation/installation/packages/symfony-filesystem-bundle.md new file mode 100644 index 0000000000..7df83021cd --- /dev/null +++ b/documentation/installation/packages/symfony-filesystem-bundle.md @@ -0,0 +1,39 @@ +--- +seo_title: "Installing Symfony Filesystem Bundle" +seo_description: > + How to install flow-php/symfony-filesystem-bundle in your PHP project using Composer. +--- + +# Symfony Filesystem Bundle + +- [Back](/documentation/installation.md) +- [Documentation](/documentation/components/bridges/symfony-filesystem-bundle.md) +- [Packagist](https://packagist.org/packages/flow-php/symfony-filesystem-bundle) + +[TOC] + +## Composer + +```bash +composer require flow-php/symfony-filesystem-bundle:~--FLOW_PHP_VERSION-- +``` + +## Core Dependencies + +- [flow-php/filesystem](/documentation/installation/packages/filesystem.md) +- [flow-php/types](/documentation/installation/packages/types.md) +- [symfony/config](https://packagist.org/packages/symfony/config) +- [symfony/console](https://packagist.org/packages/symfony/console) +- [symfony/dependency-injection](https://packagist.org/packages/symfony/dependency-injection) +- [symfony/http-kernel](https://packagist.org/packages/symfony/http-kernel) + +## Optional Filesystem Bridges + +Mount remote object stores by installing the matching bridge alongside the bundle: + +- [flow-php/filesystem-async-aws-bridge](/documentation/installation/packages/filesystem-async-aws-bridge.md) — required for the `aws-s3` protocol +- [flow-php/filesystem-azure-bridge](/documentation/installation/packages/filesystem-azure-bridge.md) — required for the `azure-blob` protocol + +## Suggested Dependencies + +- [flow-php/symfony-telemetry-bundle](/documentation/installation/packages/symfony-telemetry-bundle.md) — for telemetry integration (distributed tracing, metrics, logging) diff --git a/manifest.json b/manifest.json index 8044641e7e..cab79f5aac 100644 --- a/manifest.json +++ b/manifest.json @@ -181,6 +181,11 @@ "path": "src/bridge/symfony/http-foundation-telemetry", "type": "bridge" }, + { + "name": "flow-php/symfony-filesystem-bundle", + "path": "src/bridge/symfony/filesystem-bundle", + "type": "bridge" + }, { "name": "flow-php/symfony-postgresql-bundle", "path": "src/bridge/symfony/postgresql-bundle", diff --git a/phpdoc/bridge.symfony.filesystem.xml b/phpdoc/bridge.symfony.filesystem.xml new file mode 100644 index 0000000000..c9a8779478 --- /dev/null +++ b/phpdoc/bridge.symfony.filesystem.xml @@ -0,0 +1,24 @@ + + + Flow PHP + + ./../web/landing/build/documentation/api/bridge/symfony/filesystem + ./../var/phpdocumentor/cache/bridge/symfony/filesystem + + + + + src/bridge/symfony/filesystem-bundle/src + + filesystem + Symfony Filesystem Bundle + public + false + + + + diff --git a/phpstan.neon b/phpstan.neon index 40e51ea7a0..fcf62610d4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -31,6 +31,7 @@ parameters: - src/bridge/psr18/telemetry/src - src/bridge/symfony/http-foundation/src - src/bridge/symfony/http-foundation-telemetry/src + - src/bridge/symfony/filesystem-bundle/src - src/bridge/symfony/postgresql-bundle/src - src/bridge/symfony/telemetry-bundle/src - src/bridge/telemetry/otlp/src @@ -72,6 +73,7 @@ parameters: - src/bridge/psr18/telemetry/tests - src/bridge/symfony/http-foundation/tests - src/bridge/symfony/http-foundation-telemetry/tests + - src/bridge/symfony/filesystem-bundle/tests - src/bridge/symfony/postgresql-bundle/tests - src/bridge/symfony/telemetry-bundle/tests - src/bridge/telemetry/otlp/tests @@ -91,6 +93,7 @@ parameters: - src/lib/parquet/src/Flow/Parquet/BinaryReader/* - src/lib/parquet/src/Flow/Parquet/Dremel/ColumnData/DefinitionConverter.php - src/lib/postgresql/src/Flow/PostgreSql/Protobuf/* + - src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/config/* - src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Resources/config/* - src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/Command/* - src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/* diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 987ba12eb1..c194e4c64e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -149,6 +149,12 @@ src/bridge/symfony/http-foundation-telemetry/tests/Flow/Bridge/Symfony/HttpFoundationTelemetry/Tests/Integration + + src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit + + + src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration + src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit diff --git a/rector.src.php b/rector.src.php index cb26857e2a..654d3f4ad4 100644 --- a/rector.src.php +++ b/rector.src.php @@ -29,6 +29,7 @@ // Symfony DI requires array format for setFactory(), first-class callable syntax is not supported ArrayToFirstClassCallableRector::class => [ __DIR__ . '/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/FlowTelemetryExtension.php', + __DIR__ . '/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/DependencyInjection/Compiler/BuildFstabsPass.php', ], ]) ->withCache(__DIR__ . '/var/rector/src') diff --git a/rector.tests.php b/rector.tests.php index 58b00ddf7e..94ae72fbb5 100644 --- a/rector.tests.php +++ b/rector.tests.php @@ -218,7 +218,11 @@ RemoveParentCallWithoutParentRector::class, RemoveExtraParametersRector::class, FunctionFirstClassCallableRector::class, - ArrowFunctionDelegatingCallToFirstClassCallableRector::class + ArrowFunctionDelegatingCallToFirstClassCallableRector::class, + \Rector\Php81\Rector\Array_\ArrayToFirstClassCallableRector::class => [ + __DIR__ . '/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/BuildFstabsPassTest.php', + __DIR__ . '/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/ConfigurationTest.php', + ], ]) ->withCache(__DIR__ . '/var/rector/tests') ->withSkipPath(__DIR__ . '/src/lib/parquet/src/Flow/Parquet/Thrift') diff --git a/src/bridge/symfony/filesystem-bundle/.gitattributes b/src/bridge/symfony/filesystem-bundle/.gitattributes new file mode 100644 index 0000000000..e020972059 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/.gitattributes @@ -0,0 +1,9 @@ +*.php text eol=lf + +/.github export-ignore +/tests export-ignore + +/README.md export-ignore + +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/bridge/symfony/filesystem-bundle/.github/workflows/readonly.yaml b/src/bridge/symfony/filesystem-bundle/.github/workflows/readonly.yaml new file mode 100644 index 0000000000..24255888e1 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/.github/workflows/readonly.yaml @@ -0,0 +1,17 @@ +name: Readonly + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Hi, thank you for your contribution. + Unfortunately, this repository is read-only. It's a split from our main monorepo repository. + In order to proceed with this PR please open it against https://github.com/flow-php/flow repository. + Thank you. diff --git a/src/bridge/symfony/filesystem-bundle/CONTRIBUTING.md b/src/bridge/symfony/filesystem-bundle/CONTRIBUTING.md new file mode 100644 index 0000000000..f035b534af --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/CONTRIBUTING.md @@ -0,0 +1,6 @@ +## Contributing + +This repo is **READ ONLY**, in order to contribute to Flow PHP project, please +open PR against [flow](https://github.com/flow-php/flow) monorepo. + +Changes merged to monorepo are automatically propagated into sub repositories. diff --git a/src/bridge/symfony/filesystem-bundle/LICENSE b/src/bridge/symfony/filesystem-bundle/LICENSE new file mode 100644 index 0000000000..bc3cc4d085 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-present Flow PHP + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/bridge/symfony/filesystem-bundle/README.md b/src/bridge/symfony/filesystem-bundle/README.md new file mode 100644 index 0000000000..b7fcc8c172 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/README.md @@ -0,0 +1,14 @@ +# Flow PHP - Symfony Filesystem Bundle + +Flow PHP - Symfony Filesystem Bundle + +## Installation + +```bash +composer require flow-php/symfony-filesystem-bundle +``` + +## Documentation + +- [Documentation](https://github.com/flow-php/flow/blob/1.x/documentation/components/bridges/symfony-filesystem-bundle.md) +- [Contributing](https://github.com/flow-php/flow/blob/1.x/CONTRIBUTING.md) diff --git a/src/bridge/symfony/filesystem-bundle/composer.json b/src/bridge/symfony/filesystem-bundle/composer.json new file mode 100644 index 0000000000..effd4eb13e --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/composer.json @@ -0,0 +1,48 @@ +{ + "name": "flow-php/symfony-filesystem-bundle", + "type": "symfony-bundle", + "description": "Flow PHP - Symfony Filesystem Bundle", + "keywords": [ + "flow-php", + "symfony", + "filesystem", + "bundle" + ], + "homepage": "https://github.com/flow-php/flow", + "license": "MIT", + "require": { + "php": "~8.3.0 || ~8.4.0 || ~8.5.0", + "flow-php/filesystem": "self.version", + "flow-php/types": "self.version", + "symfony/config": "^6.4 || ^7.4 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.4 || ^8.0" + }, + "require-dev": { + "flow-php/filesystem-async-aws-bridge": "self.version", + "flow-php/filesystem-azure-bridge": "self.version", + "symfony/framework-bundle": "^6.4 || ^7.4 || ^8.0" + }, + "suggest": { + "flow-php/filesystem-async-aws-bridge": "Required for the `aws_s3` filesystem type.", + "flow-php/filesystem-azure-bridge": "Required for the `azure_blob` filesystem type." + }, + "autoload": { + "psr-4": { + "Flow\\": [ + "src/Flow" + ] + } + }, + "autoload-dev": { + "psr-4": { + "Flow\\": "tests/Flow" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "optimize-autoloader": true, + "sort-packages": true + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Attribute/AsFilesystemFactory.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Attribute/AsFilesystemFactory.php new file mode 100644 index 0000000000..3ecfd8ffc9 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Attribute/AsFilesystemFactory.php @@ -0,0 +1,13 @@ +addArgument('path', InputArgument::REQUIRED, 'File URI, e.g. memory://hello.txt') + ->addOption('fstab', 'f', InputOption::VALUE_REQUIRED, 'Fstab name; defaults to the bundle default fstab.') + ->setHelp('Stream the contents of a file URI to STDOUT. Refuses on directories.'); + } + + protected function execute(InputInterface $input, OutputInterface $output) : int + { + $io = new SymfonyStyle($input, $output); + + try { + $rawPath = type_string()->assert($input->getArgument('path')); + $fstabName = type_union(type_string(), type_null())->assert($input->getOption('fstab')); + + $table = $this->resolver->resolve($fstabName); + $path = $this->resolver->parseUri($rawPath); + $filesystem = $table->for($path); + + $status = $filesystem->status($path); + + if ($status === null) { + $io->getErrorStyle()->error(\sprintf('File not found: %s', $path->uri())); + + return Command::FAILURE; + } + + if ($status->isDirectory()) { + $io->getErrorStyle()->error(\sprintf('Refusing to cat directory: %s', $path->uri())); + + return Command::FAILURE; + } + + $stream = $filesystem->readFrom($path); + + foreach ($stream->iterate(8192) as $chunk) { + $output->write($chunk, false, OutputInterface::OUTPUT_RAW); + } + + $stream->close(); + } catch (\Throwable $e) { + $io->getErrorStyle()->error($e->getMessage()); + + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/CpCommand.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/CpCommand.php new file mode 100644 index 0000000000..0f38d5cefa --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/CpCommand.php @@ -0,0 +1,98 @@ +for($source); + } catch (\Throwable $e) { + throw new InvalidArgumentException(\sprintf('in fstab "%s": source: %s', $fstabName, $e->getMessage()), 0, $e); + } + + try { + $destFs = $table->for($dest); + } catch (\Throwable $e) { + throw new InvalidArgumentException(\sprintf('in fstab "%s": destination: %s', $fstabName, $e->getMessage()), 0, $e); + } + + $sourceStatus = $sourceFs->status($source); + + if ($sourceStatus === null) { + throw new InvalidArgumentException(\sprintf('Source not found: %s', $source->uri())); + } + + if ($sourceStatus->isDirectory()) { + throw new InvalidArgumentException(\sprintf('Refusing to copy directory: %s', $source->uri())); + } + + $sourceStream = $sourceFs->readFrom($source); + $destStream = $destFs->writeTo($dest); + + try { + foreach ($sourceStream->iterate(8192) as $chunk) { + $destStream->append($chunk); + } + } finally { + $destStream->close(); + $sourceStream->close(); + } + } + + protected function configure() : void + { + $this + ->addArgument('source', InputArgument::REQUIRED, 'Source file URI') + ->addArgument('destination', InputArgument::REQUIRED, 'Destination file URI') + ->addOption('fstab', 'f', InputOption::VALUE_REQUIRED, 'Fstab name; defaults to the bundle default fstab.') + ->setHelp(<<<'HELP' + Copies a file from one URI to another. Both URIs MUST resolve through the same + fstab; cross-fstab transfers are not supported. Streams in 8 KiB chunks so + large files do not blow up memory. + HELP); + } + + protected function execute(InputInterface $input, OutputInterface $output) : int + { + $io = new SymfonyStyle($input, $output); + + try { + $rawSource = type_string()->assert($input->getArgument('source')); + $rawDest = type_string()->assert($input->getArgument('destination')); + $fstabName = type_union(type_string(), type_null())->assert($input->getOption('fstab')); + + $table = $this->resolver->resolve($fstabName); + $source = $this->resolver->parseUri($rawSource); + $dest = $this->resolver->parseUri($rawDest); + + self::streamCopy($table, $source, $dest, $fstabName ?? $this->resolver->defaultFstabName()); + } catch (\Throwable $e) { + $io->getErrorStyle()->error($e->getMessage()); + + return Command::FAILURE; + } + + $io->success(\sprintf('Copied %s → %s', $source->uri(), $dest->uri())); + + return Command::SUCCESS; + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/FstabResolver.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/FstabResolver.php new file mode 100644 index 0000000000..f1614f1780 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/FstabResolver.php @@ -0,0 +1,90 @@ + + */ + public function availableFstabs() : array + { + if (!\method_exists($this->locator, 'getProvidedServices')) { + return []; + } + + /** @var array $services */ + $services = $this->locator->getProvidedServices(); + + return \array_keys($services); + } + + public function defaultFstabName() : string + { + return $this->defaultFstab; + } + + public function parseUri(string $raw) : Path + { + if (\preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*://#', $raw) === 1) { + return Path::from($raw); + } + + $absolute = \str_starts_with($raw, '/') ? $raw : \getcwd() . '/' . $raw; + $real = \realpath($absolute); + + return Path::from('file://' . ($real !== false ? $real : self::normalizePath($absolute))); + } + + public function resolve(?string $fstabName) : FilesystemTable + { + $name = $fstabName ?? $this->defaultFstab; + + if ($name === '' || !$this->locator->has($name)) { + throw new InvalidArgumentException(\sprintf( + 'Unknown fstab "%s". Available fstabs: [%s].', + $name, + \implode(', ', $this->availableFstabs()), + )); + } + + /** @var FilesystemTable $table */ + $table = $this->locator->get($name); + + return $table; + } + + private static function normalizePath(string $path) : string + { + $isAbsolute = \str_starts_with($path, '/'); + $segments = []; + + foreach (\explode('/', $path) as $segment) { + if ($segment === '' || $segment === '.') { + continue; + } + + if ($segment === '..') { + \array_pop($segments); + + continue; + } + + $segments[] = $segment; + } + + return ($isAbsolute ? '/' : '') . \implode('/', $segments); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/LsCommand.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/LsCommand.php new file mode 100644 index 0000000000..61db1a235c --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/LsCommand.php @@ -0,0 +1,105 @@ +addArgument('path', InputArgument::REQUIRED, 'Directory URI, e.g. memory://data or file:///tmp') + ->addOption('fstab', 'f', InputOption::VALUE_REQUIRED, 'Fstab name; defaults to the bundle default fstab.') + ->addOption('recursive', 'r', InputOption::VALUE_NONE, 'Recurse into subdirectories.') + ->addOption('long', 'l', InputOption::VALUE_NONE, 'Show type and size columns.') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Output format: "table" (default) or "json".', 'table') + ->setHelp(<<<'HELP' + Lists entries under a directory URI on the chosen fstab. + + Paths must be full URIs in the form ://. The default fstab is + used unless --fstab is provided. Use --recursive to walk subdirectories, + --long for size+type columns, and --format=json for raw JSON output. + HELP); + } + + protected function execute(InputInterface $input, OutputInterface $output) : int + { + $io = new SymfonyStyle($input, $output); + + try { + $rawPath = type_string()->assert($input->getArgument('path')); + $fstabName = type_union(type_string(), type_null())->assert($input->getOption('fstab')); + $recursive = type_boolean()->assert($input->getOption('recursive')); + $long = type_boolean()->assert($input->getOption('long')); + $format = type_string()->assert($input->getOption('format')); + + if (!\in_array($format, ['table', 'json'], true)) { + $io->getErrorStyle()->error(\sprintf('Unsupported --format "%s". Use "table" or "json".', $format)); + + return Command::FAILURE; + } + + $table = $this->resolver->resolve($fstabName); + $userPath = $this->resolver->parseUri($rawPath); + $filesystem = $table->for($userPath); + + $listPath = $userPath->isPattern() + ? $userPath + : Path::from(\rtrim($userPath->uri(), '/') . ($recursive ? '/**/*' : '/*')); + + $rows = []; + + foreach ($filesystem->list($listPath, new KeepAll()) as $status) { + $type = $status->isFile() ? 'file' : 'directory'; + $size = null; + + if ($long && $status->isFile()) { + $size = $filesystem->readFrom($status->path)->size(); + } + + $rows[] = [ + 'uri' => $status->path->uri(), + 'type' => $type, + 'size' => $size, + ]; + } + } catch (\Throwable $e) { + $io->getErrorStyle()->error($e->getMessage()); + + return Command::FAILURE; + } + + if ($format === 'json') { + $output->writeln((string) \json_encode($rows)); + + return Command::SUCCESS; + } + + if ($long) { + $io->table( + ['Type', 'Size', 'URI'], + \array_map(static fn (array $row) : array => [$row['type'], $row['size'] ?? '-', $row['uri']], $rows), + ); + } else { + $io->table(['URI'], \array_map(static fn (array $row) : array => [$row['uri']], $rows)); + } + + return Command::SUCCESS; + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/MvCommand.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/MvCommand.php new file mode 100644 index 0000000000..ec7ca084db --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/MvCommand.php @@ -0,0 +1,61 @@ +addArgument('source', InputArgument::REQUIRED, 'Source file URI') + ->addArgument('destination', InputArgument::REQUIRED, 'Destination file URI') + ->addOption('fstab', 'f', InputOption::VALUE_REQUIRED, 'Fstab name; defaults to the bundle default fstab.') + ->setHelp(<<<'HELP' + Moves a file from one URI to another by copying then deleting the source. + Both URIs MUST resolve through the same fstab. The source is deleted only + after the destination write completes successfully. + HELP); + } + + protected function execute(InputInterface $input, OutputInterface $output) : int + { + $io = new SymfonyStyle($input, $output); + + try { + $rawSource = type_string()->assert($input->getArgument('source')); + $rawDest = type_string()->assert($input->getArgument('destination')); + $fstabName = type_union(type_string(), type_null())->assert($input->getOption('fstab')); + + $table = $this->resolver->resolve($fstabName); + $source = $this->resolver->parseUri($rawSource); + $dest = $this->resolver->parseUri($rawDest); + + CpCommand::streamCopy($table, $source, $dest, $fstabName ?? $this->resolver->defaultFstabName()); + + $table->for($source)->rm($source); + } catch (\Throwable $e) { + $io->getErrorStyle()->error($e->getMessage()); + + return Command::FAILURE; + } + + $io->success(\sprintf('Moved %s → %s', $source->uri(), $dest->uri())); + + return Command::SUCCESS; + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/RmCommand.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/RmCommand.php new file mode 100644 index 0000000000..cf374f7792 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/RmCommand.php @@ -0,0 +1,69 @@ +addArgument('path', InputArgument::REQUIRED, 'File or directory URI') + ->addOption('fstab', 'f', InputOption::VALUE_REQUIRED, 'Fstab name; defaults to the bundle default fstab.') + ->addOption('recursive', 'r', InputOption::VALUE_NONE, 'Required to delete a directory.') + ->setHelp('Removes a file or directory URI. Use --recursive to delete a directory.'); + } + + protected function execute(InputInterface $input, OutputInterface $output) : int + { + $io = new SymfonyStyle($input, $output); + + try { + $rawPath = type_string()->assert($input->getArgument('path')); + $fstabName = type_union(type_string(), type_null())->assert($input->getOption('fstab')); + $recursive = type_boolean()->assert($input->getOption('recursive')); + + $table = $this->resolver->resolve($fstabName); + $path = $this->resolver->parseUri($rawPath); + $filesystem = $table->for($path); + + $status = $filesystem->status($path); + + if ($status === null) { + $io->getErrorStyle()->error(\sprintf('Path not found: %s', $path->uri())); + + return Command::FAILURE; + } + + if ($status->isDirectory() && !$recursive) { + $io->getErrorStyle()->error(\sprintf('%s is a directory; pass --recursive to delete it.', $path->uri())); + + return Command::FAILURE; + } + + $filesystem->rm($path); + } catch (\Throwable $e) { + $io->getErrorStyle()->error($e->getMessage()); + + return Command::FAILURE; + } + + $io->success(\sprintf('Removed %s', $path->uri())); + + return Command::SUCCESS; + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/StatCommand.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/StatCommand.php new file mode 100644 index 0000000000..c221a5396b --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/StatCommand.php @@ -0,0 +1,91 @@ +addArgument('path', InputArgument::REQUIRED, 'File or directory URI') + ->addOption('fstab', 'f', InputOption::VALUE_REQUIRED, 'Fstab name; defaults to the bundle default fstab.') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Output format: "human" (default) or "json".', 'human') + ->setHelp('Prints metadata (URI, protocol, type, size) for the given URI.'); + } + + protected function execute(InputInterface $input, OutputInterface $output) : int + { + $io = new SymfonyStyle($input, $output); + + try { + $rawPath = type_string()->assert($input->getArgument('path')); + $fstabName = type_union(type_string(), type_null())->assert($input->getOption('fstab')); + $format = type_string()->assert($input->getOption('format')); + + if (!\in_array($format, ['human', 'json'], true)) { + $io->getErrorStyle()->error(\sprintf('Unsupported --format "%s". Use "human" or "json".', $format)); + + return Command::FAILURE; + } + + $table = $this->resolver->resolve($fstabName); + $path = $this->resolver->parseUri($rawPath); + $filesystem = $table->for($path); + + $status = $filesystem->status($path); + + if ($status === null) { + $io->getErrorStyle()->error(\sprintf('Path not found: %s', $path->uri())); + + return Command::FAILURE; + } + + $type = $status->isFile() ? 'file' : 'directory'; + $size = $status->isFile() ? $filesystem->readFrom($status->path)->size() : null; + $protocolName = $status->path->protocol()->name; + $uri = $status->path->uri(); + $cleanPath = $status->path->path(); + } catch (\Throwable $e) { + $io->getErrorStyle()->error($e->getMessage()); + + return Command::FAILURE; + } + + if ($format === 'json') { + $output->writeln((string) \json_encode([ + 'uri' => $uri, + 'protocol' => $protocolName, + 'path' => $cleanPath, + 'type' => $type, + 'size' => $size, + ])); + + return Command::SUCCESS; + } + + $io->definitionList( + ['URI' => $uri], + ['Protocol' => $protocolName], + ['Path' => $cleanPath], + ['Type' => $type], + ['Size' => $size === null ? '-' : (string) $size], + ); + + return Command::SUCCESS; + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/TouchCommand.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/TouchCommand.php new file mode 100644 index 0000000000..e9fd17a894 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Command/TouchCommand.php @@ -0,0 +1,63 @@ +addArgument('path', InputArgument::REQUIRED, 'File URI') + ->addOption('fstab', 'f', InputOption::VALUE_REQUIRED, 'Fstab name; defaults to the bundle default fstab.') + ->addOption('force', 'F', InputOption::VALUE_NONE, 'Overwrite the file with empty content if it already exists.') + ->setHelp('Creates an empty file. Refuses on existing files unless --force is given.'); + } + + protected function execute(InputInterface $input, OutputInterface $output) : int + { + $io = new SymfonyStyle($input, $output); + + try { + $rawPath = type_string()->assert($input->getArgument('path')); + $fstabName = type_union(type_string(), type_null())->assert($input->getOption('fstab')); + $force = type_boolean()->assert($input->getOption('force')); + + $table = $this->resolver->resolve($fstabName); + $path = $this->resolver->parseUri($rawPath); + $filesystem = $table->for($path); + + if ($filesystem->status($path) !== null && !$force) { + $io->getErrorStyle()->error(\sprintf('File already exists: %s. Use --force to overwrite.', $path->uri())); + + return Command::FAILURE; + } + + $stream = $filesystem->writeTo($path); + $stream->append(''); + $stream->close(); + } catch (\Throwable $e) { + $io->getErrorStyle()->error($e->getMessage()); + + return Command::FAILURE; + } + + $io->success(\sprintf('Touched %s', $path->uri())); + + return Command::SUCCESS; + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/DependencyInjection/Compiler/BuildFstabsPass.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/DependencyInjection/Compiler/BuildFstabsPass.php new file mode 100644 index 0000000000..36f914ad99 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/DependencyInjection/Compiler/BuildFstabsPass.php @@ -0,0 +1,149 @@ +hasParameter(self::CONFIG_PARAMETER)) { + return; + } + + /** @var array{default_fstab: null|string, fstabs: array>, telemetry?: array}>} $config */ + $config = $container->getParameter(self::CONFIG_PARAMETER); + + $fstabs = $config['fstabs']; + $defaultFstab = $config['default_fstab']; + + if ($defaultFstab !== null && !\array_key_exists($defaultFstab, $fstabs)) { + throw new LogicException(\sprintf( + 'flow_filesystem: default_fstab "%s" does not match any configured fstab. Available: [%s].', + $defaultFstab, + \implode(', ', \array_keys($fstabs)), + )); + } + + $availableProtocols = $this->collectAvailableProtocols($container); + + foreach ($fstabs as $fstabName => $fstabConfig) { + $entries = []; + + foreach ($fstabConfig['filesystems'] as $protocolName => $entry) { + if (!\array_key_exists($protocolName, $availableProtocols)) { + throw new LogicException(\sprintf( + 'Fstab "%s" protocol "%s": no filesystem factory registered for this protocol. Available protocols: [%s].', + $fstabName, + $protocolName, + \implode(', ', \array_keys($availableProtocols)), + )); + } + + $entries[$protocolName] = $entry; + } + + $telemetryReference = $this->buildTelemetryConfigReference($container, $fstabName, $fstabConfig['telemetry'] ?? []); + + $definition = new Definition(FilesystemTable::class); + $definition->setFactory([FstabBuilder::class, 'build']); + $definition->setArguments([ + new Reference(RegisterFilesystemFactoriesPass::REGISTRY_SERVICE_ID), + $fstabName, + $entries, + $telemetryReference, + ]); + $definition->setPublic(false); + + $serviceId = self::FSTAB_SERVICE_PREFIX . $fstabName; + $container->setDefinition($serviceId, $definition); + + $aliasId = FilesystemTable::class . ' $' . $this->camelCase($fstabName) . 'Fstab'; + $container->setAlias($aliasId, $serviceId)->setPublic(true); + } + + if ($defaultFstab !== null) { + $container->setAlias(FilesystemTable::class, self::FSTAB_SERVICE_PREFIX . $defaultFstab)->setPublic(true); + } + } + + /** + * @param array $telemetry + */ + private function buildTelemetryConfigReference(ContainerBuilder $container, string $fstabName, array $telemetry) : ?Reference + { + if (($telemetry['enabled'] ?? false) !== true) { + return null; + } + + $telemetryServiceId = $telemetry['telemetry_service_id'] ?? null; + $clockServiceId = $telemetry['clock_service_id'] ?? null; + + if (!\is_string($telemetryServiceId) || $telemetryServiceId === '') { + throw new LogicException(\sprintf('Fstab "%s" telemetry: telemetry_service_id must be a non-empty string when enabled.', $fstabName)); + } + + if (!\is_string($clockServiceId) || $clockServiceId === '') { + throw new LogicException(\sprintf('Fstab "%s" telemetry: clock_service_id must be a non-empty string when enabled.', $fstabName)); + } + + /** @var array $optionsConfig */ + $optionsConfig = $telemetry['options'] ?? []; + $traceStreams = (bool) ($optionsConfig['trace_streams'] ?? true); + $collectMetrics = (bool) ($optionsConfig['collect_metrics'] ?? true); + + $optionsDefinition = new Definition(FilesystemTelemetryOptions::class); + $optionsDefinition->setArguments([$traceStreams, $collectMetrics]); + $optionsDefinition->setPublic(false); + + $configDefinition = new Definition(FilesystemTelemetryConfig::class); + $configDefinition->setArguments([ + new Reference($telemetryServiceId), + new Reference($clockServiceId), + $optionsDefinition, + ]); + $configDefinition->setPublic(false); + + $configServiceId = self::TELEMETRY_CONFIG_SERVICE_PREFIX . $fstabName; + $container->setDefinition($configServiceId, $configDefinition); + + return new Reference($configServiceId); + } + + private function camelCase(string $name) : string + { + return \lcfirst(\str_replace(' ', '', \ucwords(\str_replace('_', ' ', $name)))); + } + + /** + * @return array + */ + private function collectAvailableProtocols(ContainerBuilder $container) : array + { + $protocols = []; + + foreach ($container->findTaggedServiceIds(RegisterFilesystemFactoriesPass::TAG) as $serviceId => $tags) { + foreach ($tags as $tag) { + if (\array_key_exists('protocol', $tag) && \is_string($tag['protocol']) && $tag['protocol'] !== '') { + $protocols[$tag['protocol']] = $serviceId; + } + } + } + + return $protocols; + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/DependencyInjection/Compiler/RegisterFilesystemFactoriesPass.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/DependencyInjection/Compiler/RegisterFilesystemFactoriesPass.php new file mode 100644 index 0000000000..f9f72bd227 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/DependencyInjection/Compiler/RegisterFilesystemFactoriesPass.php @@ -0,0 +1,54 @@ +hasDefinition(self::REGISTRY_SERVICE_ID)) { + return; + } + + $references = []; + $seen = []; + + foreach ($container->findTaggedServiceIds(self::TAG) as $serviceId => $tags) { + foreach ($tags as $tag) { + if (!\array_key_exists('protocol', $tag) || !\is_string($tag['protocol']) || $tag['protocol'] === '') { + throw new LogicException(\sprintf( + 'Service "%s" is tagged with "%s" but is missing a non-empty "protocol" attribute.', + $serviceId, + self::TAG, + )); + } + + $protocol = $tag['protocol']; + + if (\array_key_exists($protocol, $seen)) { + throw new LogicException(\sprintf( + 'Duplicate filesystem factory for protocol "%s" (services "%s" and "%s").', + $protocol, + $seen[$protocol], + $serviceId, + )); + } + + $seen[$protocol] = $serviceId; + $references[] = new Reference($serviceId); + } + } + + $container->getDefinition(self::REGISTRY_SERVICE_ID)->setArgument(0, $references); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/DependencyInjection/Compiler/RegisterFstabLocatorPass.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/DependencyInjection/Compiler/RegisterFstabLocatorPass.php new file mode 100644 index 0000000000..85c6914e17 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/DependencyInjection/Compiler/RegisterFstabLocatorPass.php @@ -0,0 +1,40 @@ +hasParameter(BuildFstabsPass::CONFIG_PARAMETER)) { + return; + } + + /** @var array{default_fstab: null|string, fstabs: array>} $config */ + $config = $container->getParameter(BuildFstabsPass::CONFIG_PARAMETER); + + $references = []; + + foreach (\array_keys($config['fstabs']) as $fstabName) { + $references[$fstabName] = new Reference(BuildFstabsPass::FSTAB_SERVICE_PREFIX . $fstabName); + } + + $locator = new Definition(ServiceLocator::class); + $locator->setArguments([$references]); + $locator->addTag('container.service_locator'); + $locator->setPublic(false); + + $container->setDefinition(self::LOCATOR_SERVICE_ID, $locator); + + $container->setParameter(self::DEFAULT_FSTAB_PARAMETER, (string) ($config['default_fstab'] ?? '')); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Exception/Exception.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Exception/Exception.php new file mode 100644 index 0000000000..a2df9bcfdb --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Exception/Exception.php @@ -0,0 +1,9 @@ +protocol()->is($protocol->name)) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "%s" cannot create filesystem for protocol "%s".', + $this->protocol()->name, + $protocol->name, + )); + } + + $allowed = ['bucket', 'client_service_id', 'client', 'options']; + $unknown = \array_diff(\array_keys($config), $allowed); + + if ($unknown !== []) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "aws-s3" received unknown keys: [%s]. Allowed: [%s].', + \implode(', ', $unknown), + \implode(', ', $allowed), + )); + } + + if (!\is_string($config['bucket'] ?? null) || $config['bucket'] === '') { + throw new InvalidArgumentException('Filesystem factory for protocol "aws-s3" requires a non-empty `bucket` option.'); + } + + $bucket = $config['bucket']; + $clientServiceId = $config['client_service_id'] ?? null; + $clientConfig = $config['client'] ?? null; + + if (($clientServiceId === null) === ($clientConfig === null)) { + throw new InvalidArgumentException('Filesystem factory for protocol "aws-s3" requires exactly one of `client_service_id` or `client`.'); + } + + if (\is_string($clientServiceId) && $clientServiceId !== '') { + $client = $this->container->get($clientServiceId); + + if (!$client instanceof S3Client) { + throw new InvalidArgumentException(\sprintf('Service "%s" is not an instance of %s.', $clientServiceId, S3Client::class)); + } + } else { + /** @var array $clientConfig */ + $client = $this->buildClient($clientConfig ?? []); + } + + $options = $this->buildOptions($config['options'] ?? null); + + return aws_s3_filesystem($bucket, $client, $options); + } + + public function protocol() : Protocol + { + return new Protocol('aws-s3'); + } + + /** + * @param array $clientConfig + */ + private function buildClient(array $clientConfig) : S3Client + { + $allowed = ['region', 'access_key_id', 'access_key_secret', 'session_token', 'endpoint', 'path_style_endpoint', 'shared_credentials_file', 'shared_config_file', 'profile', 'debug', 'http_client_service_id', 'logger_service_id']; + $unknown = \array_diff(\array_keys($clientConfig), $allowed); + + if ($unknown !== []) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "aws-s3" `client` contains unknown keys: [%s]. Allowed: [%s].', + \implode(', ', $unknown), + \implode(', ', $allowed), + )); + } + + $httpClient = null; + $logger = null; + + if (\array_key_exists('http_client_service_id', $clientConfig)) { + $id = $clientConfig['http_client_service_id']; + + if (\is_string($id) && $id !== '') { + $httpClient = $this->container->get($id); + } + unset($clientConfig['http_client_service_id']); + } + + if (\array_key_exists('logger_service_id', $clientConfig)) { + $id = $clientConfig['logger_service_id']; + + if (\is_string($id) && $id !== '') { + $logger = $this->container->get($id); + } + unset($clientConfig['logger_service_id']); + } + + $keyMap = [ + 'region' => 'region', + 'access_key_id' => 'accessKeyId', + 'access_key_secret' => 'accessKeySecret', + 'session_token' => 'sessionToken', + 'endpoint' => 'endpoint', + 'path_style_endpoint' => 'pathStyleEndpoint', + 'shared_credentials_file' => 'sharedCredentialsFile', + 'shared_config_file' => 'sharedConfigFile', + 'profile' => 'profile', + 'debug' => 'debug', + ]; + + $asyncConfig = []; + + foreach ($keyMap as $from => $to) { + if (\array_key_exists($from, $clientConfig) && $clientConfig[$from] !== null) { + $asyncConfig[$to] = $clientConfig[$from]; + } + } + + $client = aws_s3_client($asyncConfig); + + if ($httpClient !== null || $logger !== null) { + /** @phpstan-ignore-next-line */ + return new S3Client($asyncConfig, null, $httpClient, $logger); + } + + return $client; + } + + private function buildOptions(mixed $optionsConfig) : Options + { + $options = new Options(); + + if ($optionsConfig === null) { + return $options; + } + + if (!\is_array($optionsConfig)) { + throw new InvalidArgumentException('Filesystem factory for protocol "aws-s3" `options` must be an array.'); + } + + $allowed = ['block_size']; + $unknown = \array_diff(\array_keys($optionsConfig), $allowed); + + if ($unknown !== []) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "aws-s3" `options` contains unknown keys: [%s]. Allowed: [%s].', + \implode(', ', $unknown), + \implode(', ', $allowed), + )); + } + + if (\array_key_exists('block_size', $optionsConfig) && $optionsConfig['block_size'] !== null) { + if (!\is_int($optionsConfig['block_size'])) { + throw new InvalidArgumentException('`options.block_size` must be an integer.'); + } + $options = $options->withBlockSize($optionsConfig['block_size']); + } + + return $options; + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/AzureBlobFilesystemFactory.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/AzureBlobFilesystemFactory.php new file mode 100644 index 0000000000..43fa03850c --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/AzureBlobFilesystemFactory.php @@ -0,0 +1,203 @@ +protocol()->is($protocol->name)) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "%s" cannot create filesystem for protocol "%s".', + $this->protocol()->name, + $protocol->name, + )); + } + + $allowed = ['container', 'client_service_id', 'client', 'options']; + $unknown = \array_diff(\array_keys($config), $allowed); + + if ($unknown !== []) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "azure-blob" received unknown keys: [%s]. Allowed: [%s].', + \implode(', ', $unknown), + \implode(', ', $allowed), + )); + } + + if (!\is_string($config['container'] ?? null) || $config['container'] === '') { + throw new InvalidArgumentException('Filesystem factory for protocol "azure-blob" requires a non-empty `container` option.'); + } + + $container = $config['container']; + $clientServiceId = $config['client_service_id'] ?? null; + $clientConfig = $config['client'] ?? null; + + if (($clientServiceId === null) === ($clientConfig === null)) { + throw new InvalidArgumentException('Filesystem factory for protocol "azure-blob" requires exactly one of `client_service_id` or `client`.'); + } + + if (\is_string($clientServiceId) && $clientServiceId !== '') { + $blobService = $this->container->get($clientServiceId); + + if (!$blobService instanceof BlobServiceInterface) { + throw new InvalidArgumentException(\sprintf('Service "%s" is not an instance of %s.', $clientServiceId, BlobServiceInterface::class)); + } + } else { + /** @var array $clientConfig */ + $blobService = $this->buildBlobService($container, $clientConfig ?? []); + } + + $options = $this->buildOptions($config['options'] ?? null); + + return azure_filesystem($blobService, $options); + } + + public function protocol() : Protocol + { + return new Protocol('azure-blob'); + } + + /** + * @param array $clientConfig + */ + private function buildBlobService(string $containerName, array $clientConfig) : BlobServiceInterface + { + $allowed = ['account_name', 'auth', 'url_factory', 'http_client_service', 'request_factory_service', 'stream_factory_service', 'logger_service_id']; + $unknown = \array_diff(\array_keys($clientConfig), $allowed); + + if ($unknown !== []) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "azure-blob" `client` contains unknown keys: [%s]. Allowed: [%s].', + \implode(', ', $unknown), + \implode(', ', $allowed), + )); + } + + if (!\is_string($clientConfig['account_name'] ?? null) || $clientConfig['account_name'] === '') { + throw new InvalidArgumentException('Filesystem factory for protocol "azure-blob" `client.account_name` must be a non-empty string.'); + } + + $accountName = $clientConfig['account_name']; + + if (!\is_array($clientConfig['auth'] ?? null) || !\is_string($clientConfig['auth']['shared_key'] ?? null) || $clientConfig['auth']['shared_key'] === '') { + throw new InvalidArgumentException('Filesystem factory for protocol "azure-blob" `client.auth.shared_key` must be a non-empty string.'); + } + + $authAllowed = ['shared_key']; + $authUnknown = \array_diff(\array_keys($clientConfig['auth']), $authAllowed); + + if ($authUnknown !== []) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "azure-blob" `client.auth` contains unknown keys: [%s]. Allowed: [%s].', + \implode(', ', $authUnknown), + \implode(', ', $authAllowed), + )); + } + + $authFactory = azure_shared_key_authorization_factory($accountName, $clientConfig['auth']['shared_key']); + $configuration = azure_blob_service_config($accountName, $containerName); + + $httpClient = $this->resolveService($clientConfig['http_client_service'] ?? null, ClientInterface::class) ?? Psr18ClientDiscovery::find(); + $requestFactory = $this->resolveService($clientConfig['request_factory_service'] ?? null, RequestFactoryInterface::class) ?? Psr17FactoryDiscovery::findRequestFactory(); + $streamFactory = $this->resolveService($clientConfig['stream_factory_service'] ?? null, StreamFactoryInterface::class) ?? Psr17FactoryDiscovery::findStreamFactory(); + $logger = $this->resolveService($clientConfig['logger_service_id'] ?? null, LoggerInterface::class); + + $httpFactory = azure_http_factory($requestFactory, $streamFactory); + + $urlFactoryConfig = $clientConfig['url_factory'] ?? null; + + if (\is_array($urlFactoryConfig) && \is_string($urlFactoryConfig['host'] ?? null) && $urlFactoryConfig['host'] !== '') { + $host = $urlFactoryConfig['host']; + $portRaw = $urlFactoryConfig['port'] ?? '10000'; + $port = \is_string($portRaw) ? $portRaw : (string) (\is_int($portRaw) ? $portRaw : '10000'); + $https = (bool) ($urlFactoryConfig['https'] ?? false); + $urlFactory = azurite_url_factory($host, $port, $https); + } else { + $urlFactory = azure_url_factory(); + } + + return azure_blob_service($configuration, $authFactory, $httpClient, $httpFactory, $urlFactory, $logger); + } + + private function buildOptions(mixed $optionsConfig) : Options + { + $options = new Options(); + + if ($optionsConfig === null) { + return $options; + } + + if (!\is_array($optionsConfig)) { + throw new InvalidArgumentException('Filesystem factory for protocol "azure-blob" `options` must be an array.'); + } + + $allowed = ['block_size', 'list_blob_max_results']; + $unknown = \array_diff(\array_keys($optionsConfig), $allowed); + + if ($unknown !== []) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "azure-blob" `options` contains unknown keys: [%s]. Allowed: [%s].', + \implode(', ', $unknown), + \implode(', ', $allowed), + )); + } + + if (\array_key_exists('block_size', $optionsConfig) && $optionsConfig['block_size'] !== null) { + if (!\is_int($optionsConfig['block_size'])) { + throw new InvalidArgumentException('`options.block_size` must be an integer.'); + } + $options = $options->withBlockSize($optionsConfig['block_size']); + } + + if (\array_key_exists('list_blob_max_results', $optionsConfig) && $optionsConfig['list_blob_max_results'] !== null) { + if (!\is_int($optionsConfig['list_blob_max_results'])) { + throw new InvalidArgumentException('`options.list_blob_max_results` must be an integer.'); + } + $options = $options->withListBlobMaxResults($optionsConfig['list_blob_max_results']); + } + + return $options; + } + + /** + * @template T of object + * + * @param class-string $expectedClass + * + * @return null|T + */ + private function resolveService(mixed $serviceId, string $expectedClass) : ?object + { + if (!\is_string($serviceId) || $serviceId === '') { + return null; + } + + $service = $this->container->get($serviceId); + + if (!$service instanceof $expectedClass) { + throw new InvalidArgumentException(\sprintf('Service "%s" is not an instance of %s.', $serviceId, $expectedClass)); + } + + return $service; + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/MemoryFilesystemFactory.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/MemoryFilesystemFactory.php new file mode 100644 index 0000000000..4c7760e323 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/MemoryFilesystemFactory.php @@ -0,0 +1,39 @@ +protocol()->is($protocol->name)) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "%s" cannot create filesystem for protocol "%s".', + $this->protocol()->name, + $protocol->name, + )); + } + + if ($config !== []) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "%s" does not accept any options. Unknown keys: [%s].', + $this->protocol()->name, + \implode(', ', \array_keys($config)), + )); + } + + return memory_filesystem(); + } + + public function protocol() : Protocol + { + return new Protocol('memory'); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/NativeLocalFilesystemFactory.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/NativeLocalFilesystemFactory.php new file mode 100644 index 0000000000..e723243c8b --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/NativeLocalFilesystemFactory.php @@ -0,0 +1,39 @@ +protocol()->is($protocol->name)) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "%s" cannot create filesystem for protocol "%s".', + $this->protocol()->name, + $protocol->name, + )); + } + + if ($config !== []) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "%s" does not accept any options. Unknown keys: [%s].', + $this->protocol()->name, + \implode(', ', \array_keys($config)), + )); + } + + return native_local_filesystem(); + } + + public function protocol() : Protocol + { + return new Protocol('file'); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/StdoutFilesystemFactory.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/StdoutFilesystemFactory.php new file mode 100644 index 0000000000..3325c5a7cf --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/Factory/StdoutFilesystemFactory.php @@ -0,0 +1,39 @@ +protocol()->is($protocol->name)) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "%s" cannot create filesystem for protocol "%s".', + $this->protocol()->name, + $protocol->name, + )); + } + + if ($config !== []) { + throw new InvalidArgumentException(\sprintf( + 'Filesystem factory for protocol "%s" does not accept any options. Unknown keys: [%s].', + $this->protocol()->name, + \implode(', ', \array_keys($config)), + )); + } + + return stdout_filesystem(); + } + + public function protocol() : Protocol + { + return new Protocol('stdout'); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/FilesystemFactory.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/FilesystemFactory.php new file mode 100644 index 0000000000..d75a719011 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/FilesystemFactory.php @@ -0,0 +1,17 @@ + $config + */ + public function create(Protocol $protocol, array $config) : Filesystem; + + public function protocol() : Protocol; +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/FilesystemFactoryRegistry.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/FilesystemFactoryRegistry.php new file mode 100644 index 0000000000..adcc9a1210 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/FilesystemFactoryRegistry.php @@ -0,0 +1,56 @@ + */ + private array $factories = []; + + /** + * @param iterable $factories + */ + public function __construct(iterable $factories) + { + foreach ($factories as $factory) { + $name = $factory->protocol()->name; + + if (\array_key_exists($name, $this->factories)) { + throw new LogicException(\sprintf('Duplicate filesystem factory for protocol "%s".', $name)); + } + + $this->factories[$name] = $factory; + } + } + + public function get(Protocol $protocol) : FilesystemFactory + { + if (!\array_key_exists($protocol->name, $this->factories)) { + throw new InvalidArgumentException(\sprintf( + 'No filesystem factory registered for protocol "%s". Available protocols: [%s].', + $protocol->name, + \implode(', ', \array_keys($this->factories)), + )); + } + + return $this->factories[$protocol->name]; + } + + public function has(Protocol $protocol) : bool + { + return \array_key_exists($protocol->name, $this->factories); + } + + /** + * @return list + */ + public function protocols() : array + { + return \array_keys($this->factories); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/FstabBuilder.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/FstabBuilder.php new file mode 100644 index 0000000000..db57756a39 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Filesystem/FstabBuilder.php @@ -0,0 +1,47 @@ +> $filesystems + */ + public static function build( + FilesystemFactoryRegistry $registry, + string $fstabName, + array $filesystems, + ?FilesystemTelemetryConfig $telemetryConfig = null, + ) : FilesystemTable { + $table = new FilesystemTable(); + + if ($telemetryConfig !== null) { + $table = $table->withTelemetry($telemetryConfig); + } + + foreach ($filesystems as $protocolName => $entry) { + try { + $protocol = new Protocol($protocolName); + $factory = $registry->get($protocol); + $filesystem = $factory->create($protocol, $entry); + } catch (InvalidArgumentException|\Flow\ETL\Exception\InvalidArgumentException $e) { + throw new LogicException(\sprintf( + 'Fstab "%s" protocol "%s": %s', + $fstabName, + $protocolName, + $e->getMessage(), + ), 0, $e); + } + + $table->mount($filesystem); + } + + return $table; + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/FlowFilesystemBundle.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/FlowFilesystemBundle.php new file mode 100644 index 0000000000..08adfe96c9 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/FlowFilesystemBundle.php @@ -0,0 +1,167 @@ +addCompilerPass(new RegisterFilesystemFactoriesPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10); + $container->addCompilerPass(new BuildFstabsPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); + $container->addCompilerPass(new RegisterFstabLocatorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -10); + + $container->registerAttributeForAutoconfiguration( + AsFilesystemFactory::class, + static function (ChildDefinition $definition, AsFilesystemFactory $attribute, \Reflector $reflector) : void { + $definition->addTag(RegisterFilesystemFactoriesPass::TAG, ['protocol' => $attribute->protocol]); + }, + ); + } + + #[\Override] + public function configure(DefinitionConfigurator $definition) : void + { + $definition->rootNode() + ->children() + ->scalarNode('default_fstab')->defaultNull()->end() + ->arrayNode('fstabs') + ->isRequired() + ->requiresAtLeastOneElement() + ->useAttributeAsKey('name') + ->validate() + ->ifTrue(static function (array $fstabs) : bool { + foreach (\array_keys($fstabs) as $name) { + if (!\is_string($name) || $name === '') { + return true; + } + } + + return false; + }) + ->thenInvalid('Fstab name must be a non-empty string.') + ->end() + ->arrayPrototype() + ->children() + ->arrayNode('filesystems') + ->isRequired() + ->requiresAtLeastOneElement() + ->normalizeKeys(false) + ->useAttributeAsKey('protocol') + ->validate() + ->ifTrue(static function (array $filesystems) : bool { + foreach (\array_keys($filesystems) as $protocol) { + if (!\is_string($protocol) || \preg_match(self::PROTOCOL_REGEX, $protocol) !== 1) { + return true; + } + } + + return false; + }) + ->thenInvalid('Protocol name must match ' . self::PROTOCOL_REGEX . '.') + ->end() + ->variablePrototype() + ->validate() + ->ifTrue(static fn ($v) : bool => $v !== null && !\is_array($v)) + ->thenInvalid('Filesystem entry must be an array of options (or omitted entirely).') + ->end() + ->end() + ->end() + ->append($this->telemetryNode()) + ->end() + ->end() + ->end() + ->end(); + } + + /** + * @param array $config + */ + #[\Override] + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder) : void + { + /** @var array>}> $fstabs */ + $fstabs = $config['fstabs'] ?? []; + /** @var null|string $defaultFstab */ + $defaultFstab = $config['default_fstab'] ?? null; + + foreach ($fstabs as $fstabName => $fstabConfig) { + foreach ($fstabConfig['filesystems'] as $protocol => $entry) { + $fstabs[$fstabName]['filesystems'][$protocol] = \is_array($entry) ? $entry : []; + } + } + + $config['fstabs'] = $fstabs; + + if ($defaultFstab === null) { + if (\array_key_exists('default', $fstabs)) { + $defaultFstab = 'default'; + } elseif (\count($fstabs) === 1) { + $defaultFstab = (string) \array_key_first($fstabs); + } else { + throw new InvalidConfigurationException(\sprintf( + 'flow_filesystem: no `default_fstab` was set and no fstab named "default" exists. Available fstabs: [%s].', + \implode(', ', \array_keys($fstabs)) + )); + } + } elseif (!\array_key_exists($defaultFstab, $fstabs)) { + throw new InvalidConfigurationException(\sprintf( + 'flow_filesystem: `default_fstab` is set to "%s" but no such fstab exists. Available fstabs: [%s].', + $defaultFstab, + \implode(', ', \array_keys($fstabs)) + )); + } + + $config['default_fstab'] = $defaultFstab; + + $builder->setParameter('flow_filesystem.config', $config); + + $container->import(__DIR__ . '/Resources/config/services.php'); + } + + private function telemetryNode() : NodeDefinition + { + $builder = new TreeBuilder('telemetry'); + + $builder->getRootNode() + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled')->defaultFalse()->end() + ->scalarNode('telemetry_service_id')->defaultNull()->end() + ->scalarNode('clock_service_id')->defaultNull()->end() + ->arrayNode('options') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('trace_streams')->defaultTrue()->end() + ->booleanNode('collect_metrics')->defaultTrue()->end() + ->end() + ->end() + ->end() + ->validate() + ->ifTrue(static fn (array $v) : bool => ($v['enabled'] ?? false) === true && (!\is_string($v['telemetry_service_id'] ?? null) || $v['telemetry_service_id'] === '')) + ->thenInvalid('telemetry.enabled=true requires a non-empty `telemetry_service_id`.') + ->end() + ->validate() + ->ifTrue(static fn (array $v) : bool => ($v['enabled'] ?? false) === true && (!\is_string($v['clock_service_id'] ?? null) || $v['clock_service_id'] === '')) + ->thenInvalid('telemetry.enabled=true requires a non-empty `clock_service_id`.') + ->end(); + + return $builder->getRootNode(); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Resources/config/services.php b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Resources/config/services.php new file mode 100644 index 0000000000..c21b630bfc --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/src/Flow/Bridge/Symfony/FilesystemBundle/Resources/config/services.php @@ -0,0 +1,72 @@ +services(); + + $services + ->set('.flow_filesystem.factory.file', NativeLocalFilesystemFactory::class) + ->private() + ->tag('flow_filesystem.factory', ['protocol' => 'file']); + + $services + ->set('.flow_filesystem.factory.memory', MemoryFilesystemFactory::class) + ->private() + ->tag('flow_filesystem.factory', ['protocol' => 'memory']); + + $services + ->set('.flow_filesystem.factory.stdout', StdoutFilesystemFactory::class) + ->private() + ->tag('flow_filesystem.factory', ['protocol' => 'stdout']); + + if (\class_exists(AsyncAWSS3Filesystem::class)) { + $services + ->set('.flow_filesystem.factory.aws-s3', AsyncAwsS3FilesystemFactory::class) + ->private() + ->args([service('service_container')]) + ->tag('flow_filesystem.factory', ['protocol' => 'aws-s3']); + } + + if (\class_exists(AzureBlobFilesystem::class)) { + $services + ->set('.flow_filesystem.factory.azure-blob', AzureBlobFilesystemFactory::class) + ->private() + ->args([service('service_container')]) + ->tag('flow_filesystem.factory', ['protocol' => 'azure-blob']); + } + + $services + ->set('.flow_filesystem.factory_registry', FilesystemFactoryRegistry::class) + ->private() + ->args([[]]); + + $services + ->set('.flow_filesystem.command.fstab_resolver', FstabResolver::class) + ->private() + ->args([service('flow_filesystem.fstab_locator'), '%flow_filesystem.default_fstab%']); + + foreach ([ + '.flow_filesystem.command.ls' => LsCommand::class, + '.flow_filesystem.command.cat' => CatCommand::class, + '.flow_filesystem.command.cp' => CpCommand::class, + '.flow_filesystem.command.mv' => MvCommand::class, + '.flow_filesystem.command.rm' => RmCommand::class, + '.flow_filesystem.command.stat' => StatCommand::class, + '.flow_filesystem.command.touch' => TouchCommand::class, + ] as $serviceId => $commandClass) { + $services + ->set($serviceId, $commandClass) + ->private() + ->args([service('.flow_filesystem.command.fstab_resolver')]) + ->tag('console.command'); + } +}; diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/BuildFstabsPassContext.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/BuildFstabsPassContext.php new file mode 100644 index 0000000000..7469a527c5 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/BuildFstabsPassContext.php @@ -0,0 +1,35 @@ + $config + */ + public function containerWithConfig(array $config) : ContainerBuilder + { + $container = new ContainerBuilder(); + $container->setParameter('flow_filesystem.config', $config); + + $registry = new Definition(FilesystemFactoryRegistry::class); + $registry->setArgument(0, []); + $container->setDefinition('.flow_filesystem.factory_registry', $registry); + + $memoryFactory = new Definition(MemoryFilesystemFactory::class); + $memoryFactory->addTag('flow_filesystem.factory', ['protocol' => 'memory']); + $container->setDefinition('.flow_filesystem.factory.memory', $memoryFactory); + + $nativeFactory = new Definition(NativeLocalFilesystemFactory::class); + $nativeFactory->addTag('flow_filesystem.factory', ['protocol' => 'file']); + $container->setDefinition('.flow_filesystem.factory.file', $nativeFactory); + + return $container; + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/CliCommandContext.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/CliCommandContext.php new file mode 100644 index 0000000000..c9214f7701 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/CliCommandContext.php @@ -0,0 +1,103 @@ + */ + private array $tempPaths = []; + + public function cleanup() : void + { + foreach ($this->tempPaths as $path) { + if (\is_file($path)) { + @\unlink($path); + } elseif (\is_dir($path)) { + $this->rmDirRecursive($path); + } + } + + $this->tempPaths = []; + } + + public function defaultTable() : FilesystemTable + { + $table = new FilesystemTable(); + $table->mount(new MemoryFilesystem()); + $table->mount(new NativeLocalFilesystem()); + + return $table; + } + + public function resolver(?FilesystemTable $default = null, ?FilesystemTable $secondary = null) : FstabResolver + { + $services = [ + 'default' => $default ?? $this->defaultTable(), + ]; + + if ($secondary !== null) { + $services['secondary'] = $secondary; + } + + $factories = []; + + foreach ($services as $name => $table) { + $captured = $table; + $factories[$name] = static fn () : FilesystemTable => $captured; + } + + return new FstabResolver(new ServiceLocator($factories), 'default'); + } + + public function secondaryMemoryOnly() : FilesystemTable + { + $table = new FilesystemTable(); + $table->mount(new MemoryFilesystem()); + + return $table; + } + + public function seedMemoryFile(FilesystemTable $table, string $uri, string $content) : void + { + $path = path($uri); + $stream = $table->for($path)->writeTo($path); + $stream->append($content); + $stream->close(); + } + + public function tempDir() : string + { + $dir = \sys_get_temp_dir() . '/flow_filesystem_cli_' . \bin2hex(\random_bytes(6)); + \mkdir($dir, 0o777, true); + $this->tempPaths[] = $dir; + + return $dir; + } + + private function rmDirRecursive(string $dir) : void + { + foreach (\scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $dir . '/' . $entry; + + if (\is_dir($path)) { + $this->rmDirRecursive($path); + } else { + @\unlink($path); + } + } + + @\rmdir($dir); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/ConfigurationContext.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/ConfigurationContext.php new file mode 100644 index 0000000000..e13b5ad4bb --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/ConfigurationContext.php @@ -0,0 +1,47 @@ +symfony = new SymfonyContext(); + } + + /** + * @param array $flowFilesystemConfig + * @param null|callable(ContainerBuilder): void $containerConfigurator + * + * @return array{default_fstab: string, fstabs: array>, telemetry: array{enabled: bool, telemetry_service_id: null|string, clock_service_id: null|string, options: array{trace_streams: bool, collect_metrics: bool}}}>} + */ + public function processConfig(array $flowFilesystemConfig, ?callable $containerConfigurator = null) : array + { + $kernel = $this->symfony->bootKernel([ + 'config' => static function (TestKernel $kernel) use ($flowFilesystemConfig, $containerConfigurator) : void { + $kernel->addTestExtensionConfig('flow_filesystem', $flowFilesystemConfig); + + if ($containerConfigurator !== null) { + $kernel->addTestContainerConfigurator($containerConfigurator); + } + }, + ]); + + /** @var array{default_fstab: string, fstabs: array>, telemetry: array{enabled: bool, telemetry_service_id: null|string, clock_service_id: null|string, options: array{trace_streams: bool, collect_metrics: bool}}}>} $config */ + $config = $kernel->getContainer()->getParameter('flow_filesystem.config'); + + return $config; + } + + public function shutdown() : void + { + $this->symfony->shutdown(); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/SymfonyContext.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/SymfonyContext.php new file mode 100644 index 0000000000..581d8cd0d5 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Context/SymfonyContext.php @@ -0,0 +1,88 @@ +kernel !== null) { + $this->shutdown(); + } + + $this->kernel = new TestKernel('test', false); + + if (isset($options['config']) && \is_callable($options['config'])) { + $options['config']($this->kernel); + } + + $this->kernel->boot(); + + return $this->kernel; + } + + public function getContainer() : ContainerInterface + { + if ($this->kernel === null) { + throw new \LogicException('Kernel has not been booted. Call bootKernel() first.'); + } + + return $this->kernel->getContainer(); + } + + public function getKernel() : TestKernel + { + if ($this->kernel === null) { + throw new \LogicException('Kernel has not been booted. Call bootKernel() first.'); + } + + return $this->kernel; + } + + /** + * @template T of object + * + * @param class-string $typeClass + * + * @return T + */ + public function getService(string $serviceId, string $typeClass) : object + { + return type_instance_of($typeClass)->assert($this->getContainer()->get($serviceId)); + } + + public function shutdown() : void + { + if ($this->kernel === null) { + return; + } + + $cacheDir = $this->kernel->getCacheDir(); + $logDir = $this->kernel->getLogDir(); + + $this->kernel->shutdown(); + $this->kernel = null; + + $filesystem = new Filesystem(); + + if ($filesystem->exists($cacheDir)) { + $filesystem->remove($cacheDir); + } + + if ($filesystem->exists($logDir)) { + $filesystem->remove($logDir); + } + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/CapturingFilesystemFactory.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/CapturingFilesystemFactory.php new file mode 100644 index 0000000000..11ce28df2c --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/CapturingFilesystemFactory.php @@ -0,0 +1,32 @@ +}> */ + public array $calls = []; + + public function __construct( + private readonly string $protocolName, + private readonly Filesystem $filesystem, + ) { + } + + public function create(Protocol $protocol, array $config) : Filesystem + { + $this->calls[] = ['protocol' => $protocol->name, 'config' => $config]; + + return $this->filesystem; + } + + public function protocol() : Protocol + { + return new Protocol($this->protocolName); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/MemoryStubFilesystemFactory.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/MemoryStubFilesystemFactory.php new file mode 100644 index 0000000000..e2d5b8a313 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/MemoryStubFilesystemFactory.php @@ -0,0 +1,26 @@ +protocolName); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/StubFilesystemFactory.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/StubFilesystemFactory.php new file mode 100644 index 0000000000..bfb6404c44 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/StubFilesystemFactory.php @@ -0,0 +1,31 @@ +filesystem === null) { + throw new \RuntimeException('StubFilesystemFactory was not configured with a Filesystem instance.'); + } + + return $this->filesystem; + } + + public function protocol() : Protocol + { + return new Protocol($this->protocolName); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/TelemetryStubFactory.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/TelemetryStubFactory.php new file mode 100644 index 0000000000..730d460338 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Double/TelemetryStubFactory.php @@ -0,0 +1,16 @@ +> */ + private array $testBundles = []; + + /** @var array */ + private array $testConfigs = []; + + /** @var array */ + private array $testContainerConfigurators = []; + + /** @var array> */ + private array $testExtensionConfigs = []; + + private readonly string $testId; + + public function __construct(string $environment = 'test', bool $debug = true) + { + $this->testId = \bin2hex(\random_bytes(8)); + + parent::__construct($environment, $debug); + } + + /** + * @param class-string $bundleClass + */ + public function addTestBundle(string $bundleClass) : void + { + $this->testBundles[] = $bundleClass; + } + + public function addTestConfig(string $configPath) : void + { + $this->testConfigs[] = $configPath; + } + + /** + * @param callable(ContainerBuilder): void $configurator + */ + public function addTestContainerConfigurator(callable $configurator) : void + { + $this->testContainerConfigurators[] = $configurator; + } + + /** + * @param array $config + */ + public function addTestExtensionConfig(string $extension, array $config) : void + { + $this->testExtensionConfigs[$extension] = \array_merge( + $this->testExtensionConfigs[$extension] ?? [], + $config + ); + } + + #[\Override] + public function getCacheDir() : string + { + return \sys_get_temp_dir() . '/flow_filesystem_bundle_test/' . $this->environment . '/' . $this->testId . '/cache'; + } + + #[\Override] + public function getLogDir() : string + { + return \sys_get_temp_dir() . '/flow_filesystem_bundle_test/' . $this->environment . '/' . $this->testId . '/log'; + } + + #[\Override] + public function getProjectDir() : string + { + return __DIR__ . '/..'; + } + + public function registerBundles() : iterable + { + yield new FlowFilesystemBundle(); + + foreach ($this->testBundles as $bundleClass) { + yield new $bundleClass(); + } + } + + public function registerContainerConfiguration(LoaderInterface $loader) : void + { + foreach ($this->testConfigs as $configPath) { + $loader->load($configPath); + } + + $loader->load(function (ContainerBuilder $container) : void { + foreach ($this->testExtensionConfigs as $extension => $config) { + $container->loadFromExtension($extension, $config); + } + + foreach ($this->testContainerConfigurators as $configurator) { + $configurator($container); + } + + $container->setParameter('kernel.secret', 'test_secret_' . $this->testId); + }); + } + + protected function build(ContainerBuilder $container) : void + { + parent::build($container); + + $container->addCompilerPass(new class implements CompilerPassInterface { + public function process(ContainerBuilder $container) : void + { + foreach ($container->getDefinitions() as $id => $definition) { + if (\str_starts_with($id, 'flow.filesystem') || \str_starts_with($id, 'test.')) { + $definition->setPublic(true); + } + } + + foreach ($container->getAliases() as $id => $alias) { + if (\str_starts_with($id, 'Flow\\Filesystem\\') || \str_starts_with($id, 'Flow\\Bridge\\Symfony\\FilesystemBundle\\') || \str_starts_with($id, 'flow.filesystem')) { + $alias->setPublic(true); + } + } + } + }); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/Command/FilesystemCommandsIntegrationTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/Command/FilesystemCommandsIntegrationTest.php new file mode 100644 index 0000000000..d87020424b --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/Command/FilesystemCommandsIntegrationTest.php @@ -0,0 +1,169 @@ + */ + private array $tempPaths = []; + + protected function tearDown() : void + { + foreach ($this->tempPaths as $dir) { + $this->rmRf($dir); + } + + $this->tempPaths = []; + + parent::tearDown(); + } + + public function test_cp_between_protocols_in_same_fstab() : void + { + $resolver = $this->bootWithMultiFstab(); + + $table = $resolver->resolve(null); + $stream = $table->for(path('memory://src.txt'))->writeTo(path('memory://src.txt')); + $stream->append('integration-payload'); + $stream->close(); + + $dir = $this->makeTempDir(); + $destUri = 'file://' . $dir . '/out.txt'; + + $tester = new CommandTester(new CpCommand($resolver)); + $exit = $tester->execute(['source' => 'memory://src.txt', 'destination' => $destUri]); + + self::assertSame(Command::SUCCESS, $exit); + self::assertSame('integration-payload', \file_get_contents($dir . '/out.txt')); + self::assertNotNull($table->for(path('memory://src.txt'))->status(path('memory://src.txt'))); + } + + public function test_cp_fails_when_protocol_missing_in_chosen_fstab() : void + { + $resolver = $this->bootWithMultiFstab(); + + $secondary = $resolver->resolve('secondary'); + $stream = $secondary->for(path('memory://only.txt'))->writeTo(path('memory://only.txt')); + $stream->append('x'); + $stream->close(); + + $tester = new CommandTester(new CpCommand($resolver)); + $exit = $tester->execute([ + 'source' => 'memory://only.txt', + 'destination' => 'file:///tmp/nope.txt', + '--fstab' => 'secondary', + ]); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('secondary', $tester->getDisplay()); + } + + public function test_fstab_option_routes_to_secondary() : void + { + $resolver = $this->bootWithMultiFstab(); + + $secondary = $resolver->resolve('secondary'); + $stream = $secondary->for(path('memory://data/hello.txt'))->writeTo(path('memory://data/hello.txt')); + $stream->append('secondary-only'); + $stream->close(); + + $tester = new CommandTester(new LsCommand($resolver)); + $exit = $tester->execute(['path' => 'memory://data', '--fstab' => 'secondary']); + + self::assertSame(Command::SUCCESS, $exit); + self::assertStringContainsString('memory://data/hello.txt', $tester->getDisplay()); + } + + public function test_mv_removes_source_after_success() : void + { + $resolver = $this->bootWithMultiFstab(); + + $table = $resolver->resolve(null); + $stream = $table->for(path('memory://src.txt'))->writeTo(path('memory://src.txt')); + $stream->append('moving'); + $stream->close(); + + $dir = $this->makeTempDir(); + $destUri = 'file://' . $dir . '/moved.txt'; + + $tester = new CommandTester(new MvCommand($resolver)); + $exit = $tester->execute(['source' => 'memory://src.txt', 'destination' => $destUri]); + + self::assertSame(Command::SUCCESS, $exit); + self::assertSame('moving', \file_get_contents($dir . '/moved.txt')); + self::assertNull($table->for(path('memory://src.txt'))->status(path('memory://src.txt'))); + } + + private function bootWithMultiFstab() : FstabResolver + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_filesystem', [ + 'fstabs' => [ + 'default' => ['filesystems' => ['memory' => [], 'file' => []]], + 'secondary' => ['filesystems' => ['memory' => []]], + ], + ]); + + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container) : void { + $container->addCompilerPass(new class implements CompilerPassInterface { + public function process(ContainerBuilder $container) : void + { + if ($container->hasDefinition('.flow_filesystem.command.fstab_resolver')) { + $container->getDefinition('.flow_filesystem.command.fstab_resolver')->setPublic(true); + } + } + }); + }); + }, + ]); + + /** @var FstabResolver $resolver */ + $resolver = $this->getContainer()->get('.flow_filesystem.command.fstab_resolver'); + + return $resolver; + } + + private function makeTempDir() : string + { + $dir = \sys_get_temp_dir() . '/flow_fs_cli_int_' . \bin2hex(\random_bytes(6)); + \mkdir($dir, 0o777, true); + $this->tempPaths[] = $dir; + + return $dir; + } + + private function rmRf(string $dir) : void + { + if (!\is_dir($dir)) { + return; + } + + foreach (\scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $p = $dir . '/' . $entry; + + if (\is_dir($p)) { + $this->rmRf($p); + } else { + @\unlink($p); + } + } + + @\rmdir($dir); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/FlowFilesystemBundleTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/FlowFilesystemBundleTest.php new file mode 100644 index 0000000000..220092f725 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/FlowFilesystemBundleTest.php @@ -0,0 +1,34 @@ +bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_filesystem', [ + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'file' => ['type' => 'native_local'], + ], + ], + ], + ]); + }, + ]); + + self::assertTrue($this->getContainer()->hasParameter('kernel.secret')); + + $bundles = $kernel->getBundles(); + self::assertArrayHasKey('FlowFilesystemBundle', $bundles); + self::assertInstanceOf(FlowFilesystemBundle::class, $bundles['FlowFilesystemBundle']); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/FstabServiceRegistrationTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/FstabServiceRegistrationTest.php new file mode 100644 index 0000000000..d6fbae8c8d --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/FstabServiceRegistrationTest.php @@ -0,0 +1,113 @@ +bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_filesystem', [ + 'default_fstab' => 'secondary', + 'fstabs' => [ + 'primary' => [ + 'filesystems' => [ + 'file' => [], + ], + ], + 'secondary' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + ], + ]); + }, + ]); + + $table = $this->symfonyContext()->getService(FilesystemTable::class, FilesystemTable::class); + + self::assertSame('memory', $table->for(new Protocol('memory'))->protocol()->name); + } + + public function test_named_argument_alias_resolves_to_fstab() : void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_filesystem', [ + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + ], + ]); + }, + ]); + + $aliasId = FilesystemTable::class . ' $defaultFstab'; + $table = $this->symfonyContext()->getService($aliasId, FilesystemTable::class); + + self::assertSame('memory', $table->for(new Protocol('memory'))->protocol()->name); + } + + public function test_registers_default_fstab_with_multiple_filesystems() : void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_filesystem', [ + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'file' => [], + 'memory' => [], + ], + ], + ], + ]); + }, + ]); + + $table = $this->symfonyContext()->getService(FilesystemTable::class . ' $defaultFstab', FilesystemTable::class); + + self::assertSame('file', $table->for(new Protocol('file'))->protocol()->name); + self::assertSame('memory', $table->for(new Protocol('memory'))->protocol()->name); + } + + public function test_registers_secondary_fstab_separately() : void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_filesystem', [ + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'file' => [], + 'memory' => [], + ], + ], + 'secondary' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + ], + ]); + }, + ]); + + $default = $this->symfonyContext()->getService(FilesystemTable::class . ' $defaultFstab', FilesystemTable::class); + $secondary = $this->symfonyContext()->getService(FilesystemTable::class . ' $secondaryFstab', FilesystemTable::class); + + self::assertNotSame($default, $secondary); + self::assertCount(2, $default->filesystems()); + self::assertCount(1, $secondary->filesystems()); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/KernelTestCase.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/KernelTestCase.php new file mode 100644 index 0000000000..6913b12c61 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Integration/KernelTestCase.php @@ -0,0 +1,48 @@ +context = new SymfonyContext(); + } + + protected function tearDown() : void + { + $this->context->shutdown(); + } + + /** + * @param array{config?: callable(TestKernel): void} $options + */ + protected function bootKernel(array $options = []) : TestKernel + { + return $this->context->bootKernel($options); + } + + protected function getContainer() : ContainerInterface + { + return $this->context->getContainer(); + } + + protected function getKernel() : TestKernel + { + return $this->context->getKernel(); + } + + protected function symfonyContext() : SymfonyContext + { + return $this->context; + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Attribute/AsFilesystemFactoryTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Attribute/AsFilesystemFactoryTest.php new file mode 100644 index 0000000000..7e3347c4f8 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Attribute/AsFilesystemFactoryTest.php @@ -0,0 +1,27 @@ +getAttributes(\Attribute::class); + + self::assertCount(1, $attributes); + self::assertSame(\Attribute::TARGET_CLASS, $attributes[0]->newInstance()->flags); + } + + public function test_exposes_protocol_as_public_readonly() : void + { + $attribute = new AsFilesystemFactory('my-fs'); + + self::assertSame('my-fs', $attribute->protocol); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/CatCommandTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/CatCommandTest.php new file mode 100644 index 0000000000..257c0ca33e --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/CatCommandTest.php @@ -0,0 +1,59 @@ +context = new CliCommandContext(); + } + + protected function tearDown() : void + { + $this->context->cleanup(); + } + + public function test_fails_when_file_missing() : void + { + $tester = new CommandTester(new CatCommand($this->context->resolver())); + $exit = $tester->execute(['path' => 'memory://nope.txt']); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('File not found', $tester->getDisplay()); + } + + public function test_streams_file_to_stdout() : void + { + $table = $this->context->defaultTable(); + $this->context->seedMemoryFile($table, 'memory://hello.txt', 'hello world'); + + $tester = new CommandTester(new CatCommand($this->context->resolver($table))); + $exit = $tester->execute(['path' => 'memory://hello.txt']); + + self::assertSame(Command::SUCCESS, $exit); + self::assertSame('hello world', $tester->getDisplay()); + } + + public function test_treats_path_without_protocol_as_local_file() : void + { + $dir = $this->context->tempDir(); + \file_put_contents($dir . '/payload.txt', 'local-bytes'); + + $tester = new CommandTester(new CatCommand($this->context->resolver())); + $exit = $tester->execute(['path' => $dir . '/payload.txt']); + + self::assertSame(Command::SUCCESS, $exit); + self::assertSame('local-bytes', $tester->getDisplay()); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/CpCommandTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/CpCommandTest.php new file mode 100644 index 0000000000..62b9e22edc --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/CpCommandTest.php @@ -0,0 +1,71 @@ +context = new CliCommandContext(); + } + + protected function tearDown() : void + { + $this->context->cleanup(); + } + + public function test_copies_between_protocols_on_same_fstab() : void + { + $table = $this->context->defaultTable(); + $this->context->seedMemoryFile($table, 'memory://src.txt', 'payload'); + + $dir = $this->context->tempDir(); + $destUri = 'file://' . $dir . '/dest.txt'; + + $tester = new CommandTester(new CpCommand($this->context->resolver($table))); + $exit = $tester->execute(['source' => 'memory://src.txt', 'destination' => $destUri]); + + self::assertSame(Command::SUCCESS, $exit); + self::assertSame('payload', \file_get_contents($dir . '/dest.txt')); + // source still there + self::assertNotNull($table->for(path('memory://src.txt'))->status(path('memory://src.txt'))); + } + + public function test_fails_when_protocol_not_mounted_in_chosen_fstab() : void + { + $table = $this->context->secondaryMemoryOnly(); + $this->context->seedMemoryFile($table, 'memory://src.txt', 'payload'); + + $resolver = $this->context->resolver(null, $table); + $tester = new CommandTester(new CpCommand($resolver)); + $exit = $tester->execute([ + 'source' => 'memory://src.txt', + 'destination' => 'file:///tmp/x.txt', + '--fstab' => 'secondary', + ]); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('secondary', $tester->getDisplay()); + self::assertStringContainsString('file', $tester->getDisplay()); + } + + public function test_fails_when_source_missing() : void + { + $tester = new CommandTester(new CpCommand($this->context->resolver())); + $exit = $tester->execute(['source' => 'memory://absent', 'destination' => 'memory://dest']); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('Source not found', $tester->getDisplay()); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/FstabResolverTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/FstabResolverTest.php new file mode 100644 index 0000000000..0ef6f00591 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/FstabResolverTest.php @@ -0,0 +1,79 @@ +context = new CliCommandContext(); + } + + protected function tearDown() : void + { + $this->context->cleanup(); + } + + public function test_available_fstabs_lists_all_provided_services() : void + { + $resolver = $this->context->resolver(null, $this->context->secondaryMemoryOnly()); + + self::assertSame(['default', 'secondary'], $resolver->availableFstabs()); + } + + public function test_default_fstab_name_exposed() : void + { + self::assertSame('default', $this->context->resolver()->defaultFstabName()); + } + + public function test_parse_uri_accepts_valid_uri() : void + { + self::assertSame('memory://data/file.txt', $this->context->resolver()->parseUri('memory://data/file.txt')->uri()); + } + + public function test_parse_uri_assumes_local_filesystem_for_absolute_path() : void + { + $path = $this->context->resolver()->parseUri('/tmp/file.txt'); + self::assertSame('file', $path->protocol()->name); + self::assertSame('/tmp/file.txt', $path->path()); + } + + public function test_parse_uri_resolves_relative_path_against_cwd() : void + { + $cwd = (string) \getcwd(); + $path = $this->context->resolver()->parseUri('file.txt'); + self::assertSame('file', $path->protocol()->name); + self::assertSame($cwd . '/file.txt', $path->path()); + } + + public function test_resolve_returns_default_when_name_null() : void + { + $default = $this->context->defaultTable(); + self::assertSame($default, $this->context->resolver($default)->resolve(null)); + } + + public function test_resolve_returns_requested_fstab() : void + { + $secondary = $this->context->secondaryMemoryOnly(); + $resolver = $this->context->resolver(null, $secondary); + + self::assertSame($secondary, $resolver->resolve('secondary')); + } + + public function test_resolve_throws_on_unknown_fstab() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown fstab "nope"'); + + $this->context->resolver()->resolve('nope'); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/LsCommandTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/LsCommandTest.php new file mode 100644 index 0000000000..b5f28afa19 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/LsCommandTest.php @@ -0,0 +1,104 @@ +context = new CliCommandContext(); + } + + protected function tearDown() : void + { + $this->context->cleanup(); + } + + public function test_fails_on_invalid_format() : void + { + $tester = new CommandTester(new LsCommand($this->context->resolver())); + $exit = $tester->execute(['path' => 'memory://d', '--format' => 'xml']); + + self::assertSame(Command::FAILURE, $exit); + } + + public function test_json_output_emits_array() : void + { + $table = $this->context->defaultTable(); + $this->context->seedMemoryFile($table, 'memory://j/a.txt', 'A'); + + $tester = new CommandTester(new LsCommand($this->context->resolver($table))); + $tester->execute(['path' => 'memory://j', '--format' => 'json']); + + /** @var array $data */ + $data = \json_decode(\trim($tester->getDisplay()), true, flags: \JSON_THROW_ON_ERROR); + self::assertCount(1, $data); + self::assertSame('memory://j/a.txt', $data[0]['uri']); + } + + public function test_lists_directories_alongside_files() : void + { + $dir = $this->context->tempDir(); + \mkdir($dir . '/sub'); + \file_put_contents($dir . '/a.txt', 'A'); + + $tester = new CommandTester(new LsCommand($this->context->resolver())); + $exit = $tester->execute(['path' => $dir, '--long' => true]); + + self::assertSame(Command::SUCCESS, $exit); + $display = $tester->getDisplay(); + self::assertStringContainsString('a.txt', $display); + self::assertStringContainsString('sub', $display); + self::assertStringContainsString('directory', $display); + } + + public function test_lists_memory_directory_entries() : void + { + $table = $this->context->defaultTable(); + $this->context->seedMemoryFile($table, 'memory://dir/a.txt', 'A'); + $this->context->seedMemoryFile($table, 'memory://dir/b.txt', 'BB'); + + $tester = new CommandTester(new LsCommand($this->context->resolver($table))); + $exit = $tester->execute(['path' => 'memory://dir']); + + self::assertSame(Command::SUCCESS, $exit); + $display = $tester->getDisplay(); + self::assertStringContainsString('memory://dir/a.txt', $display); + self::assertStringContainsString('memory://dir/b.txt', $display); + } + + public function test_long_output_includes_size_column() : void + { + $table = $this->context->defaultTable(); + $this->context->seedMemoryFile($table, 'memory://d/a.txt', 'hello'); + + $tester = new CommandTester(new LsCommand($this->context->resolver($table))); + $tester->execute(['path' => 'memory://d', '--long' => true]); + + $display = $tester->getDisplay(); + self::assertStringContainsString('Size', $display); + self::assertStringContainsString('5', $display); + } + + public function test_treats_path_without_protocol_as_local_directory() : void + { + $dir = $this->context->tempDir(); + \file_put_contents($dir . '/a.txt', 'A'); + + $tester = new CommandTester(new LsCommand($this->context->resolver())); + $exit = $tester->execute(['path' => $dir]); + + self::assertSame(Command::SUCCESS, $exit); + self::assertStringContainsString('a.txt', $tester->getDisplay()); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/MvCommandTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/MvCommandTest.php new file mode 100644 index 0000000000..f102576ef0 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/MvCommandTest.php @@ -0,0 +1,51 @@ +context = new CliCommandContext(); + } + + protected function tearDown() : void + { + $this->context->cleanup(); + } + + public function test_fails_when_source_missing() : void + { + $tester = new CommandTester(new MvCommand($this->context->resolver())); + $exit = $tester->execute(['source' => 'memory://absent', 'destination' => 'memory://dest']); + + self::assertSame(Command::FAILURE, $exit); + } + + public function test_moves_between_protocols_on_same_fstab() : void + { + $table = $this->context->defaultTable(); + $this->context->seedMemoryFile($table, 'memory://src.txt', 'payload'); + + $dir = $this->context->tempDir(); + $destUri = 'file://' . $dir . '/dest.txt'; + + $tester = new CommandTester(new MvCommand($this->context->resolver($table))); + $exit = $tester->execute(['source' => 'memory://src.txt', 'destination' => $destUri]); + + self::assertSame(Command::SUCCESS, $exit); + self::assertSame('payload', \file_get_contents($dir . '/dest.txt')); + self::assertNull($table->for(path('memory://src.txt'))->status(path('memory://src.txt'))); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/RmCommandTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/RmCommandTest.php new file mode 100644 index 0000000000..fa5ab333ec --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/RmCommandTest.php @@ -0,0 +1,74 @@ +context = new CliCommandContext(); + } + + protected function tearDown() : void + { + $this->context->cleanup(); + } + + public function test_fails_on_missing_path() : void + { + $tester = new CommandTester(new RmCommand($this->context->resolver())); + $exit = $tester->execute(['path' => 'memory://absent']); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('Path not found', $tester->getDisplay()); + } + + public function test_recursive_removes_directory() : void + { + $dir = $this->context->tempDir(); + \file_put_contents($dir . '/a.txt', 'hi'); + + $table = $this->context->defaultTable(); + $tester = new CommandTester(new RmCommand($this->context->resolver($table))); + $exit = $tester->execute(['path' => 'file://' . $dir, '--recursive' => true]); + + self::assertSame(Command::SUCCESS, $exit); + self::assertFalse(\is_dir($dir)); + } + + public function test_removes_memory_file() : void + { + $table = $this->context->defaultTable(); + $this->context->seedMemoryFile($table, 'memory://to-delete.txt', 'x'); + + $tester = new CommandTester(new RmCommand($this->context->resolver($table))); + $exit = $tester->execute(['path' => 'memory://to-delete.txt']); + + self::assertSame(Command::SUCCESS, $exit); + self::assertNull($table->for(path('memory://to-delete.txt'))->status(path('memory://to-delete.txt'))); + } + + public function test_requires_recursive_for_directory() : void + { + $dir = $this->context->tempDir(); + \file_put_contents($dir . '/a.txt', 'hi'); + + $table = $this->context->defaultTable(); + $tester = new CommandTester(new RmCommand($this->context->resolver($table))); + $exit = $tester->execute(['path' => 'file://' . $dir]); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('--recursive', $tester->getDisplay()); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/StatCommandTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/StatCommandTest.php new file mode 100644 index 0000000000..6cf081342f --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/StatCommandTest.php @@ -0,0 +1,88 @@ +context = new CliCommandContext(); + } + + protected function tearDown() : void + { + $this->context->cleanup(); + } + + public function test_fails_on_invalid_format() : void + { + $tester = new CommandTester(new StatCommand($this->context->resolver())); + $exit = $tester->execute(['path' => 'memory://x', '--format' => 'xml']); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('Unsupported --format', $tester->getDisplay()); + } + + public function test_fails_on_missing_path() : void + { + $tester = new CommandTester(new StatCommand($this->context->resolver())); + $exit = $tester->execute(['path' => 'memory://absent.txt']); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('Path not found', $tester->getDisplay()); + } + + public function test_human_output_shows_metadata() : void + { + $table = $this->context->defaultTable(); + $this->context->seedMemoryFile($table, 'memory://file.txt', 'hello'); + + $tester = new CommandTester(new StatCommand($this->context->resolver($table))); + $exit = $tester->execute(['path' => 'memory://file.txt']); + + self::assertSame(Command::SUCCESS, $exit); + $display = $tester->getDisplay(); + self::assertStringContainsString('memory://file.txt', $display); + self::assertStringContainsString('file', $display); + self::assertStringContainsString('memory', $display); + } + + public function test_json_output_schema() : void + { + $table = $this->context->defaultTable(); + $this->context->seedMemoryFile($table, 'memory://f.bin', 'abcd'); + + $tester = new CommandTester(new StatCommand($this->context->resolver($table))); + $tester->execute(['path' => 'memory://f.bin', '--format' => 'json']); + + /** @var array{uri: string, protocol: string, path: string, type: string, size: null|int} $data */ + $data = \json_decode(\trim($tester->getDisplay()), true, flags: \JSON_THROW_ON_ERROR); + + self::assertSame('memory://f.bin', $data['uri']); + self::assertSame('memory', $data['protocol']); + self::assertSame('file', $data['type']); + self::assertSame(4, $data['size']); + } + + public function test_treats_path_without_protocol_as_local_file() : void + { + $dir = $this->context->tempDir(); + \file_put_contents($dir . '/local.txt', 'hi'); + + $tester = new CommandTester(new StatCommand($this->context->resolver())); + $exit = $tester->execute(['path' => $dir . '/local.txt']); + + self::assertSame(Command::SUCCESS, $exit); + self::assertStringContainsString('file', $tester->getDisplay()); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/TouchCommandTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/TouchCommandTest.php new file mode 100644 index 0000000000..1eb56bfc58 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Command/TouchCommandTest.php @@ -0,0 +1,72 @@ +context = new CliCommandContext(); + } + + protected function tearDown() : void + { + $this->context->cleanup(); + } + + public function test_creates_empty_file() : void + { + $table = $this->context->defaultTable(); + $tester = new CommandTester(new TouchCommand($this->context->resolver($table))); + $exit = $tester->execute(['path' => 'memory://new.txt']); + + self::assertSame(Command::SUCCESS, $exit); + self::assertNotNull($table->for(path('memory://new.txt'))->status(path('memory://new.txt'))); + } + + public function test_force_overwrites_to_empty() : void + { + $table = $this->context->defaultTable(); + $this->context->seedMemoryFile($table, 'memory://exists.txt', 'not-empty'); + + $tester = new CommandTester(new TouchCommand($this->context->resolver($table))); + $exit = $tester->execute(['path' => 'memory://exists.txt', '--force' => true]); + + self::assertSame(Command::SUCCESS, $exit); + self::assertSame('', $table->for(path('memory://exists.txt'))->readFrom(path('memory://exists.txt'))->content()); + } + + public function test_refuses_existing_without_force() : void + { + $table = $this->context->defaultTable(); + $this->context->seedMemoryFile($table, 'memory://exists.txt', 'content'); + + $tester = new CommandTester(new TouchCommand($this->context->resolver($table))); + $exit = $tester->execute(['path' => 'memory://exists.txt']); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('already exists', $tester->getDisplay()); + } + + public function test_treats_path_without_protocol_as_local_file() : void + { + $dir = $this->context->tempDir(); + + $tester = new CommandTester(new TouchCommand($this->context->resolver())); + $exit = $tester->execute(['path' => $dir . '/marker']); + + self::assertSame(Command::SUCCESS, $exit); + self::assertFileExists($dir . '/marker'); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/ConfigurationTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/ConfigurationTest.php new file mode 100644 index 0000000000..67b027d776 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/ConfigurationTest.php @@ -0,0 +1,286 @@ +context = new ConfigurationContext(); + } + + protected function tearDown() : void + { + $this->context->shutdown(); + } + + public function test_default_fstab_implicitly_resolves_to_fstab_named_default() : void + { + $config = $this->context->processConfig([ + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'file' => [], + ], + ], + 'extra' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + ], + ]); + + self::assertSame('default', $config['default_fstab']); + } + + public function test_invalid_default_fstab_points_to_missing_fstab_throws() : void + { + $this->expectException(InvalidConfigurationException::class); + + $this->context->processConfig([ + 'default_fstab' => 'nope', + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'file' => [], + ], + ], + ], + ]); + } + + public function test_invalid_empty_fstabs_throws() : void + { + $this->expectException(InvalidConfigurationException::class); + + $this->context->processConfig(['fstabs' => []]); + } + + public function test_invalid_fstab_with_zero_filesystems_throws() : void + { + $this->expectException(InvalidConfigurationException::class); + + $this->context->processConfig([ + 'fstabs' => [ + 'default' => ['filesystems' => []], + ], + ]); + } + + public function test_invalid_multi_fstab_without_explicit_default_and_without_default_name_throws() : void + { + $this->expectException(InvalidConfigurationException::class); + + $this->context->processConfig([ + 'fstabs' => [ + 'primary' => [ + 'filesystems' => [ + 'file' => [], + ], + ], + 'secondary' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + ], + ]); + } + + public function test_invalid_protocol_name_regex_throws_numeric_start() : void + { + $this->expectException(InvalidConfigurationException::class); + + $this->context->processConfig([ + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + '1bad' => [], + ], + ], + ], + ]); + } + + public function test_invalid_protocol_name_regex_throws_special_chars() : void + { + $this->expectException(InvalidConfigurationException::class); + + $this->context->processConfig([ + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'bad/protocol' => [], + ], + ], + ], + ]); + } + + public function test_invalid_telemetry_enabled_without_clock_service_id_throws() : void + { + $this->expectException(InvalidConfigurationException::class); + + $this->context->processConfig([ + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'file' => [], + ], + 'telemetry' => ['enabled' => true, 'telemetry_service_id' => 'x'], + ], + ], + ]); + } + + public function test_invalid_telemetry_enabled_without_telemetry_service_id_throws() : void + { + $this->expectException(InvalidConfigurationException::class); + + $this->context->processConfig([ + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'file' => [], + ], + 'telemetry' => ['enabled' => true, 'clock_service_id' => 'x'], + ], + ], + ]); + } + + public function test_single_fstab_without_explicit_default_resolves_to_only_fstab() : void + { + $config = $this->context->processConfig([ + 'fstabs' => [ + 'primary' => [ + 'filesystems' => [ + 'file' => [], + ], + ], + ], + ]); + + self::assertSame('primary', $config['default_fstab']); + } + + public function test_valid_hyphenated_protocol_keys_are_preserved_without_normalization() : void + { + $config = $this->context->processConfig([ + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'aws-s3' => ['bucket' => 'b', 'client_service_id' => 'x'], + 'azure-blob' => ['container' => 'c', 'client_service_id' => 'y'], + ], + ], + ], + ]); + + self::assertArrayHasKey('aws-s3', $config['fstabs']['default']['filesystems']); + self::assertArrayHasKey('azure-blob', $config['fstabs']['default']['filesystems']); + self::assertArrayNotHasKey('aws_s3', $config['fstabs']['default']['filesystems']); + self::assertArrayNotHasKey('azure_blob', $config['fstabs']['default']['filesystems']); + } + + public function test_valid_minimal_single_fstab() : void + { + $config = $this->context->processConfig([ + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'file' => [], + ], + ], + ], + ]); + + self::assertSame('default', $config['default_fstab']); + self::assertArrayHasKey('file', $config['fstabs']['default']['filesystems']); + } + + public function test_valid_multi_fstab_with_explicit_default() : void + { + $config = $this->context->processConfig([ + 'default_fstab' => 'secondary', + 'fstabs' => [ + 'primary' => [ + 'filesystems' => [ + 'file' => [], + ], + ], + 'secondary' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + ], + ]); + + self::assertSame('secondary', $config['default_fstab']); + self::assertArrayHasKey('primary', $config['fstabs']); + self::assertArrayHasKey('secondary', $config['fstabs']); + } + + public function test_valid_telemetry_disabled_by_default() : void + { + $config = $this->context->processConfig([ + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'file' => [], + ], + ], + ], + ]); + + self::assertFalse($config['fstabs']['default']['telemetry']['enabled']); + } + + public function test_valid_telemetry_fully_configured() : void + { + $config = $this->context->processConfig( + [ + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'file' => [], + ], + 'telemetry' => [ + 'enabled' => true, + 'telemetry_service_id' => 'app.telemetry', + 'clock_service_id' => 'app.clock', + 'options' => ['trace_streams' => false, 'collect_metrics' => false], + ], + ], + ], + ], + static function (ContainerBuilder $container) : void { + $container->setDefinition( + 'app.telemetry', + (new Definition(Telemetry::class)) + ->setFactory([TelemetryStubFactory::class, 'create']) + ->setPublic(true), + ); + $container->setDefinition('app.clock', (new Definition(SystemClock::class))->setPublic(true)); + }, + ); + + self::assertTrue($config['fstabs']['default']['telemetry']['enabled']); + self::assertSame('app.telemetry', $config['fstabs']['default']['telemetry']['telemetry_service_id']); + self::assertFalse($config['fstabs']['default']['telemetry']['options']['trace_streams']); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/BuildFstabsPassTelemetryTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/BuildFstabsPassTelemetryTest.php new file mode 100644 index 0000000000..50f4addfcd --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/BuildFstabsPassTelemetryTest.php @@ -0,0 +1,127 @@ +context = new BuildFstabsPassContext(); + } + + public function test_creates_telemetry_config_definition_when_enabled() : void + { + $container = $this->context->containerWithConfig([ + 'default_fstab' => 'default', + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'memory' => [], + ], + 'telemetry' => [ + 'enabled' => true, + 'telemetry_service_id' => 'app.telemetry', + 'clock_service_id' => 'app.clock', + 'options' => ['trace_streams' => false, 'collect_metrics' => true], + ], + ], + ], + ]); + + (new BuildFstabsPass())->process($container); + + self::assertTrue($container->hasDefinition('.flow_filesystem.telemetry_config.default')); + $configDefinition = $container->getDefinition('.flow_filesystem.telemetry_config.default'); + self::assertSame(FilesystemTelemetryConfig::class, $configDefinition->getClass()); + + $configArgs = $configDefinition->getArguments(); + self::assertInstanceOf(Reference::class, $configArgs[0]); + self::assertSame('app.telemetry', (string) $configArgs[0]); + self::assertInstanceOf(Reference::class, $configArgs[1]); + self::assertSame('app.clock', (string) $configArgs[1]); + self::assertInstanceOf(Definition::class, $configArgs[2]); + self::assertSame(FilesystemTelemetryOptions::class, $configArgs[2]->getClass()); + self::assertSame([false, true], $configArgs[2]->getArguments()); + + $fstabArgs = $container->getDefinition('.flow_filesystem.fstab.default')->getArguments(); + self::assertInstanceOf(Reference::class, $fstabArgs[3]); + self::assertSame('.flow_filesystem.telemetry_config.default', (string) $fstabArgs[3]); + } + + public function test_passes_null_telemetry_when_disabled() : void + { + $container = $this->context->containerWithConfig([ + 'default_fstab' => 'default', + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'memory' => [], + ], + 'telemetry' => ['enabled' => false], + ], + ], + ]); + + (new BuildFstabsPass())->process($container); + + $arguments = $container->getDefinition('.flow_filesystem.fstab.default')->getArguments(); + self::assertNull($arguments[3]); + } + + public function test_throws_when_telemetry_enabled_without_clock_service_id() : void + { + $container = $this->context->containerWithConfig([ + 'default_fstab' => 'default', + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'memory' => [], + ], + 'telemetry' => [ + 'enabled' => true, + 'telemetry_service_id' => 'app.telemetry', + ], + ], + ], + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('clock_service_id'); + + (new BuildFstabsPass())->process($container); + } + + public function test_throws_when_telemetry_enabled_without_telemetry_service_id() : void + { + $container = $this->context->containerWithConfig([ + 'default_fstab' => 'default', + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'memory' => [], + ], + 'telemetry' => [ + 'enabled' => true, + 'clock_service_id' => 'app.clock', + ], + ], + ], + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('telemetry_service_id'); + + (new BuildFstabsPass())->process($container); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/BuildFstabsPassTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/BuildFstabsPassTest.php new file mode 100644 index 0000000000..09c4f823a0 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/BuildFstabsPassTest.php @@ -0,0 +1,223 @@ +context = new BuildFstabsPassContext(); + } + + public function test_camel_cases_snake_case_fstab_names_in_alias() : void + { + $container = $this->context->containerWithConfig([ + 'default_fstab' => 'my_warehouse', + 'fstabs' => [ + 'my_warehouse' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + ], + ]); + + (new BuildFstabsPass())->process($container); + + self::assertTrue($container->hasAlias(FilesystemTable::class . ' $myWarehouseFstab')); + } + + public function test_does_not_register_fqcn_alias_when_default_fstab_null() : void + { + $container = $this->context->containerWithConfig([ + 'default_fstab' => null, + 'fstabs' => [ + 'primary' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + ], + ]); + + (new BuildFstabsPass())->process($container); + + self::assertFalse($container->hasAlias(FilesystemTable::class)); + } + + public function test_does_nothing_when_config_parameter_missing() : void + { + $container = new ContainerBuilder(); + + (new BuildFstabsPass())->process($container); + + self::assertFalse($container->hasDefinition('.flow_filesystem.fstab.default')); + } + + public function test_passes_entries_as_third_argument() : void + { + $container = $this->context->containerWithConfig([ + 'default_fstab' => 'default', + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'memory' => ['foo' => 'bar'], + ], + ], + ], + ]); + + (new BuildFstabsPass())->process($container); + + $arguments = $container->getDefinition('.flow_filesystem.fstab.default')->getArguments(); + self::assertSame(['memory' => ['foo' => 'bar']], $arguments[2]); + } + + public function test_passes_registry_reference_and_fstab_name_as_first_arguments() : void + { + $container = $this->context->containerWithConfig([ + 'default_fstab' => 'default', + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + ], + ]); + + (new BuildFstabsPass())->process($container); + + $arguments = $container->getDefinition('.flow_filesystem.fstab.default')->getArguments(); + self::assertInstanceOf(Reference::class, $arguments[0]); + self::assertSame('.flow_filesystem.factory_registry', (string) $arguments[0]); + self::assertSame('default', $arguments[1]); + } + + public function test_registers_fqcn_alias_pointing_to_default_fstab() : void + { + $container = $this->context->containerWithConfig([ + 'default_fstab' => 'secondary', + 'fstabs' => [ + 'primary' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + 'secondary' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + ], + ]); + + (new BuildFstabsPass())->process($container); + + self::assertTrue($container->hasAlias(FilesystemTable::class)); + $alias = $container->getAlias(FilesystemTable::class); + self::assertSame('.flow_filesystem.fstab.secondary', (string) $alias); + self::assertTrue($alias->isPublic()); + } + + public function test_registers_named_argument_alias_for_each_fstab() : void + { + $container = $this->context->containerWithConfig([ + 'default_fstab' => 'default', + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + ], + ]); + + (new BuildFstabsPass())->process($container); + + $aliasId = FilesystemTable::class . ' $defaultFstab'; + self::assertTrue($container->hasAlias($aliasId)); + $alias = $container->getAlias($aliasId); + self::assertSame('.flow_filesystem.fstab.default', (string) $alias); + self::assertTrue($alias->isPublic()); + } + + public function test_registers_private_fstab_service_for_each_configured_fstab() : void + { + $container = $this->context->containerWithConfig([ + 'default_fstab' => 'default', + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + 'secondary' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + ], + ]); + + (new BuildFstabsPass())->process($container); + + $defaultDef = $container->getDefinition('.flow_filesystem.fstab.default'); + self::assertSame(FilesystemTable::class, $defaultDef->getClass()); + self::assertFalse($defaultDef->isPublic()); + self::assertSame([FstabBuilder::class, 'build'], $defaultDef->getFactory()); + + self::assertTrue($container->hasDefinition('.flow_filesystem.fstab.secondary')); + self::assertFalse($container->getDefinition('.flow_filesystem.fstab.secondary')->isPublic()); + } + + public function test_throws_on_unknown_default_fstab() : void + { + $container = $this->context->containerWithConfig([ + 'default_fstab' => 'nope', + 'fstabs' => [ + 'primary' => [ + 'filesystems' => [ + 'memory' => [], + ], + ], + ], + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('flow_filesystem: default_fstab "nope"'); + + (new BuildFstabsPass())->process($container); + } + + public function test_throws_on_unknown_protocol_at_compile_time() : void + { + $container = $this->context->containerWithConfig([ + 'default_fstab' => 'default', + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'ftp' => [], + ], + ], + ], + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Fstab "default" protocol "ftp": no filesystem factory registered for this protocol'); + + (new BuildFstabsPass())->process($container); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/RegisterFilesystemFactoriesPassTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/RegisterFilesystemFactoriesPassTest.php new file mode 100644 index 0000000000..80c60ba55b --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/RegisterFilesystemFactoriesPassTest.php @@ -0,0 +1,93 @@ +process($container); + + self::assertFalse($container->hasDefinition('.flow_filesystem.factory_registry')); + } + + public function test_injects_tagged_factories_as_references() : void + { + $container = new ContainerBuilder(); + $container->setDefinition('.flow_filesystem.factory_registry', (new Definition(FilesystemFactoryRegistry::class))->setArgument(0, [])); + + $memory = new Definition(MemoryFilesystemFactory::class); + $memory->addTag('flow_filesystem.factory', ['protocol' => 'memory']); + $container->setDefinition('.flow_filesystem.factory.memory', $memory); + + $native = new Definition(NativeLocalFilesystemFactory::class); + $native->addTag('flow_filesystem.factory', ['protocol' => 'file']); + $container->setDefinition('.flow_filesystem.factory.file', $native); + + (new RegisterFilesystemFactoriesPass())->process($container); + + $argument = $container->getDefinition('.flow_filesystem.factory_registry')->getArgument(0); + self::assertIsArray($argument); + self::assertCount(2, $argument); + self::assertContainsOnlyInstancesOf(Reference::class, $argument); + + $ids = \array_map(static fn (Reference $r) : string => (string) $r, $argument); + \sort($ids); + self::assertSame(['.flow_filesystem.factory.file', '.flow_filesystem.factory.memory'], $ids); + } + + public function test_leaves_registry_empty_when_no_tagged_services() : void + { + $container = new ContainerBuilder(); + $container->setDefinition('.flow_filesystem.factory_registry', (new Definition(FilesystemFactoryRegistry::class))->setArgument(0, [])); + + (new RegisterFilesystemFactoriesPass())->process($container); + + self::assertSame([], $container->getDefinition('.flow_filesystem.factory_registry')->getArgument(0)); + } + + public function test_throws_on_duplicate_protocol_across_services() : void + { + $container = new ContainerBuilder(); + $container->setDefinition('.flow_filesystem.factory_registry', (new Definition(FilesystemFactoryRegistry::class))->setArgument(0, [])); + + $a = new Definition(MemoryFilesystemFactory::class); + $a->addTag('flow_filesystem.factory', ['protocol' => 'memory']); + $container->setDefinition('.flow_filesystem.factory.a', $a); + + $b = new Definition(MemoryFilesystemFactory::class); + $b->addTag('flow_filesystem.factory', ['protocol' => 'memory']); + $container->setDefinition('.flow_filesystem.factory.b', $b); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Duplicate filesystem factory for protocol "memory"'); + + (new RegisterFilesystemFactoriesPass())->process($container); + } + + public function test_throws_on_tag_without_protocol_attribute() : void + { + $container = new ContainerBuilder(); + $container->setDefinition('.flow_filesystem.factory_registry', (new Definition(FilesystemFactoryRegistry::class))->setArgument(0, [])); + + $memory = new Definition(MemoryFilesystemFactory::class); + $memory->addTag('flow_filesystem.factory'); + $container->setDefinition('.flow_filesystem.factory.memory', $memory); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('missing a non-empty "protocol" attribute'); + + (new RegisterFilesystemFactoriesPass())->process($container); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/RegisterFstabLocatorPassTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/RegisterFstabLocatorPassTest.php new file mode 100644 index 0000000000..1ea10b5c9a --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/DependencyInjection/Compiler/RegisterFstabLocatorPassTest.php @@ -0,0 +1,73 @@ +setParameter(BuildFstabsPass::CONFIG_PARAMETER, [ + 'default_fstab' => null, + 'fstabs' => ['primary' => ['filesystems' => ['memory' => []]]], + ]); + + (new RegisterFstabLocatorPass())->process($container); + + self::assertSame('', $container->getParameter(RegisterFstabLocatorPass::DEFAULT_FSTAB_PARAMETER)); + } + + public function test_does_nothing_when_config_parameter_missing() : void + { + $container = new ContainerBuilder(); + + (new RegisterFstabLocatorPass())->process($container); + + self::assertFalse($container->hasDefinition(RegisterFstabLocatorPass::LOCATOR_SERVICE_ID)); + self::assertFalse($container->hasParameter(RegisterFstabLocatorPass::DEFAULT_FSTAB_PARAMETER)); + } + + public function test_exposes_default_fstab_parameter() : void + { + $container = new ContainerBuilder(); + $container->setParameter(BuildFstabsPass::CONFIG_PARAMETER, [ + 'default_fstab' => 'primary', + 'fstabs' => ['primary' => ['filesystems' => ['memory' => []]]], + ]); + + (new RegisterFstabLocatorPass())->process($container); + + self::assertSame('primary', $container->getParameter(RegisterFstabLocatorPass::DEFAULT_FSTAB_PARAMETER)); + } + + public function test_registers_locator_with_reference_per_fstab() : void + { + $container = new ContainerBuilder(); + $container->setParameter(BuildFstabsPass::CONFIG_PARAMETER, [ + 'default_fstab' => 'default', + 'fstabs' => [ + 'default' => ['filesystems' => ['memory' => []]], + 'secondary' => ['filesystems' => ['memory' => []]], + ], + ]); + + (new RegisterFstabLocatorPass())->process($container); + + $definition = $container->getDefinition(RegisterFstabLocatorPass::LOCATOR_SERVICE_ID); + self::assertSame(ServiceLocator::class, $definition->getClass()); + self::assertFalse($definition->isPublic()); + self::assertSame(['container.service_locator' => [[]]], $definition->getTags()); + + /** @var array $refs */ + $refs = $definition->getArgument(0); + self::assertSame(['default', 'secondary'], \array_keys($refs)); + self::assertSame('.flow_filesystem.fstab.default', (string) $refs['default']); + self::assertSame('.flow_filesystem.fstab.secondary', (string) $refs['secondary']); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/AsyncAwsS3FilesystemFactoryTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/AsyncAwsS3FilesystemFactoryTest.php new file mode 100644 index 0000000000..f9d5b4c951 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/AsyncAwsS3FilesystemFactoryTest.php @@ -0,0 +1,185 @@ +create(new Protocol('aws-s3'), [ + 'bucket' => 'b', + 'client' => ['region' => 'us-east-1', 'access_key_id' => 'k', 'access_key_secret' => 's'], + 'options' => ['block_size' => 6 * 1024 * 1024], + ]); + + self::assertInstanceOf(AsyncAWSS3Filesystem::class, $filesystem); + } + + public function test_mode_a_builds_filesystem_from_client_service_id() : void + { + $container = new ContainerBuilder(); + $container->set('app.s3_client', new S3Client(['accessKeyId' => 'k', 'accessKeySecret' => 's', 'region' => 'us-east-1'])); + + $filesystem = (new AsyncAwsS3FilesystemFactory($container))->create(new Protocol('aws-s3'), ['bucket' => 'my-bucket', 'client_service_id' => 'app.s3_client']); + + self::assertInstanceOf(AsyncAWSS3Filesystem::class, $filesystem); + self::assertSame('aws-s3', $filesystem->protocol()->name); + } + + public function test_mode_b_builds_client_from_inline_config() : void + { + $filesystem = (new AsyncAwsS3FilesystemFactory(new ContainerBuilder()))->create(new Protocol('aws-s3'), [ + 'bucket' => 'my-bucket', + 'client' => [ + 'region' => 'eu-west-1', + 'access_key_id' => 'key', + 'access_key_secret' => 'secret', + ], + ]); + + self::assertInstanceOf(AsyncAWSS3Filesystem::class, $filesystem); + } + + public function test_mode_b_resolves_http_client_and_logger_from_container() : void + { + $container = new ContainerBuilder(); + $container->set('app.http_client', HttpClient::create()); + $container->set('app.logger', new NullLogger()); + + $filesystem = (new AsyncAwsS3FilesystemFactory($container))->create(new Protocol('aws-s3'), [ + 'bucket' => 'b', + 'client' => [ + 'region' => 'us-east-1', + 'access_key_id' => 'k', + 'access_key_secret' => 's', + 'http_client_service_id' => 'app.http_client', + 'logger_service_id' => 'app.logger', + ], + ]); + + self::assertInstanceOf(AsyncAWSS3Filesystem::class, $filesystem); + } + + public function test_protocol_returns_aws_s3() : void + { + self::assertSame('aws-s3', (new AsyncAwsS3FilesystemFactory(new ContainerBuilder()))->protocol()->name); + } + + public function test_throws_on_invalid_block_size_type() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`options.block_size` must be an integer'); + + (new AsyncAwsS3FilesystemFactory(new ContainerBuilder()))->create(new Protocol('aws-s3'), [ + 'bucket' => 'b', + 'client' => ['region' => 'us-east-1'], + 'options' => ['block_size' => 'not-an-int'], + ]); + } + + public function test_throws_on_missing_bucket() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('non-empty `bucket`'); + + (new AsyncAwsS3FilesystemFactory(new ContainerBuilder()))->create(new Protocol('aws-s3'), ['client' => []]); + } + + public function test_throws_on_unknown_client_keys() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`client` contains unknown keys: [nope]'); + + (new AsyncAwsS3FilesystemFactory(new ContainerBuilder()))->create(new Protocol('aws-s3'), [ + 'bucket' => 'b', + 'client' => ['nope' => 'x'], + ]); + } + + public function test_throws_on_unknown_options_keys() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`options` contains unknown keys: [nope]'); + + (new AsyncAwsS3FilesystemFactory(new ContainerBuilder()))->create(new Protocol('aws-s3'), [ + 'bucket' => 'b', + 'client' => ['region' => 'us-east-1'], + 'options' => ['nope' => 1], + ]); + } + + public function test_throws_on_unknown_top_level_keys() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('received unknown keys: [container]'); + + (new AsyncAwsS3FilesystemFactory(new ContainerBuilder()))->create(new Protocol('aws-s3'), [ + 'bucket' => 'b', + 'client' => ['region' => 'us-east-1'], + 'container' => 'nope', + ]); + } + + public function test_throws_on_wrong_protocol() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Filesystem factory for protocol "aws-s3" cannot create filesystem for protocol "file"'); + + (new AsyncAwsS3FilesystemFactory(new ContainerBuilder()))->create(new Protocol('file'), ['bucket' => 'b', 'client' => []]); + } + + public function test_throws_when_both_client_modes_supplied() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('exactly one of `client_service_id` or `client`'); + + (new AsyncAwsS3FilesystemFactory(new ContainerBuilder()))->create(new Protocol('aws-s3'), [ + 'bucket' => 'b', + 'client_service_id' => 'some.service', + 'client' => ['region' => 'us-east-1'], + ]); + } + + public function test_throws_when_client_service_not_s3_client() : void + { + $container = new ContainerBuilder(); + $container->set('app.wrong', new \stdClass()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is not an instance of'); + + (new AsyncAwsS3FilesystemFactory($container))->create(new Protocol('aws-s3'), ['bucket' => 'b', 'client_service_id' => 'app.wrong']); + } + + public function test_throws_when_no_client_mode_supplied() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('exactly one of'); + + (new AsyncAwsS3FilesystemFactory(new ContainerBuilder()))->create(new Protocol('aws-s3'), ['bucket' => 'b']); + } + + public function test_throws_when_options_is_not_array() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`options` must be an array'); + + (new AsyncAwsS3FilesystemFactory(new ContainerBuilder()))->create(new Protocol('aws-s3'), [ + 'bucket' => 'b', + 'client' => ['region' => 'us-east-1'], + 'options' => 'not-an-array', + ]); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/AzureBlobFilesystemFactoryTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/AzureBlobFilesystemFactoryTest.php new file mode 100644 index 0000000000..21b211f983 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/AzureBlobFilesystemFactoryTest.php @@ -0,0 +1,253 @@ +create(new Protocol('azure-blob'), [ + 'container' => 'c', + 'client' => ['account_name' => 'a', 'auth' => ['shared_key' => \base64_encode('k')]], + 'options' => ['block_size' => 8 * 1024 * 1024, 'list_blob_max_results' => 100], + ]); + + self::assertInstanceOf(AzureBlobFilesystem::class, $filesystem); + } + + public function test_mode_a_builds_filesystem_from_client_service_id() : void + { + $container = new ContainerBuilder(); + $container->set('app.blob_service', self::createStub(BlobServiceInterface::class)); + + $filesystem = (new AzureBlobFilesystemFactory($container))->create(new Protocol('azure-blob'), [ + 'container' => 'my-container', + 'client_service_id' => 'app.blob_service', + ]); + + self::assertInstanceOf(AzureBlobFilesystem::class, $filesystem); + self::assertSame('azure-blob', $filesystem->protocol()->name); + } + + public function test_mode_b_builds_blob_service_from_inline_shared_key() : void + { + $filesystem = (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('azure-blob'), [ + 'container' => 'my-container', + 'client' => [ + 'account_name' => 'myaccount', + 'auth' => ['shared_key' => \base64_encode('secret-key')], + ], + ]); + + self::assertInstanceOf(AzureBlobFilesystem::class, $filesystem); + } + + public function test_mode_b_resolves_optional_http_and_factories_from_container() : void + { + $container = new ContainerBuilder(); + $container->set('app.http_client', new Psr18Client()); + $container->set('app.request_factory', new Psr17Factory()); + $container->set('app.stream_factory', new Psr17Factory()); + $container->set('app.logger', new NullLogger()); + + $filesystem = (new AzureBlobFilesystemFactory($container))->create(new Protocol('azure-blob'), [ + 'container' => 'c', + 'client' => [ + 'account_name' => 'a', + 'auth' => ['shared_key' => \base64_encode('k')], + 'http_client_service' => 'app.http_client', + 'request_factory_service' => 'app.request_factory', + 'stream_factory_service' => 'app.stream_factory', + 'logger_service_id' => 'app.logger', + ], + ]); + + self::assertInstanceOf(AzureBlobFilesystem::class, $filesystem); + } + + public function test_mode_b_uses_azurite_url_factory_when_host_set() : void + { + $filesystem = (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('azure-blob'), [ + 'container' => 'my-container', + 'client' => [ + 'account_name' => 'devstoreaccount1', + 'auth' => ['shared_key' => \base64_encode('devkey')], + 'url_factory' => ['host' => '127.0.0.1', 'port' => '10000', 'https' => false], + ], + ]); + + self::assertInstanceOf(AzureBlobFilesystem::class, $filesystem); + } + + public function test_protocol_returns_azure_blob() : void + { + self::assertSame('azure-blob', (new AzureBlobFilesystemFactory(new ContainerBuilder()))->protocol()->name); + } + + public function test_throws_on_invalid_block_size_type() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('block_size'); + + (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('azure-blob'), [ + 'container' => 'c', + 'client' => ['account_name' => 'a', 'auth' => ['shared_key' => 'k']], + 'options' => ['block_size' => 'nope'], + ]); + } + + public function test_throws_on_invalid_list_blob_max_results_type() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('list_blob_max_results'); + + (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('azure-blob'), [ + 'container' => 'c', + 'client' => ['account_name' => 'a', 'auth' => ['shared_key' => 'k']], + 'options' => ['list_blob_max_results' => 'nope'], + ]); + } + + public function test_throws_on_missing_container() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('non-empty `container`'); + + (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('azure-blob'), ['client' => []]); + } + + public function test_throws_on_unknown_auth_keys() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`client.auth` contains unknown keys: [sas_token]'); + + (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('azure-blob'), [ + 'container' => 'c', + 'client' => ['account_name' => 'a', 'auth' => ['shared_key' => \base64_encode('k'), 'sas_token' => 'x']], + ]); + } + + public function test_throws_on_unknown_client_keys() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`client` contains unknown keys: [nope]'); + + (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('azure-blob'), [ + 'container' => 'c', + 'client' => ['account_name' => 'a', 'auth' => ['shared_key' => \base64_encode('k')], 'nope' => 1], + ]); + } + + public function test_throws_on_unknown_options_keys() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`options` contains unknown keys: [nope]'); + + (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('azure-blob'), [ + 'container' => 'c', + 'client' => ['account_name' => 'a', 'auth' => ['shared_key' => \base64_encode('k')]], + 'options' => ['nope' => 1], + ]); + } + + public function test_throws_on_unknown_top_level_keys() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('received unknown keys: [bucket]'); + + (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('azure-blob'), [ + 'container' => 'c', + 'client' => ['account_name' => 'a', 'auth' => ['shared_key' => \base64_encode('k')]], + 'bucket' => 'no', + ]); + } + + public function test_throws_on_wrong_protocol() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Filesystem factory for protocol "azure-blob" cannot create filesystem for protocol "file"'); + + (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('file'), ['container' => 'c', 'client' => []]); + } + + public function test_throws_when_both_client_modes_supplied() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('exactly one of'); + + (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('azure-blob'), [ + 'container' => 'c', + 'client_service_id' => 'x', + 'client' => ['account_name' => 'a'], + ]); + } + + public function test_throws_when_client_service_not_blob_service() : void + { + $container = new ContainerBuilder(); + $container->set('app.wrong', new \stdClass()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is not an instance of'); + + (new AzureBlobFilesystemFactory($container))->create(new Protocol('azure-blob'), [ + 'container' => 'c', + 'client_service_id' => 'app.wrong', + ]); + } + + public function test_throws_when_mode_b_missing_account_name() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`client.account_name`'); + + (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('azure-blob'), [ + 'container' => 'c', + 'client' => ['auth' => ['shared_key' => 'k']], + ]); + } + + public function test_throws_when_mode_b_missing_shared_key() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`client.auth.shared_key`'); + + (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('azure-blob'), [ + 'container' => 'c', + 'client' => ['account_name' => 'a'], + ]); + } + + public function test_throws_when_no_client_mode_supplied() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('exactly one of'); + + (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('azure-blob'), ['container' => 'c']); + } + + public function test_throws_when_options_is_not_array() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`options` must be an array'); + + (new AzureBlobFilesystemFactory(new ContainerBuilder()))->create(new Protocol('azure-blob'), [ + 'container' => 'c', + 'client' => ['account_name' => 'a', 'auth' => ['shared_key' => 'k']], + 'options' => 'nope', + ]); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/MemoryFilesystemFactoryTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/MemoryFilesystemFactoryTest.php new file mode 100644 index 0000000000..1cc41032fa --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/MemoryFilesystemFactoryTest.php @@ -0,0 +1,41 @@ +create(new Protocol('memory'), []); + + self::assertSame('memory', $filesystem->protocol()->name); + } + + public function test_protocol_returns_expected_name() : void + { + self::assertSame('memory', (new MemoryFilesystemFactory())->protocol()->name); + } + + public function test_throws_on_protocol_mismatch() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Filesystem factory for protocol "memory" cannot create filesystem for protocol "file".'); + + (new MemoryFilesystemFactory())->create(new Protocol('file'), []); + } + + public function test_throws_on_unknown_options() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Filesystem factory for protocol "memory" does not accept any options. Unknown keys: [foo, bar].'); + + (new MemoryFilesystemFactory())->create(new Protocol('memory'), ['foo' => 1, 'bar' => 2]); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/NativeLocalFilesystemFactoryTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/NativeLocalFilesystemFactoryTest.php new file mode 100644 index 0000000000..8ba96a22ae --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/NativeLocalFilesystemFactoryTest.php @@ -0,0 +1,41 @@ +create(new Protocol('file'), []); + + self::assertSame('file', $filesystem->protocol()->name); + } + + public function test_protocol_returns_expected_name() : void + { + self::assertSame('file', (new NativeLocalFilesystemFactory())->protocol()->name); + } + + public function test_throws_on_protocol_mismatch() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Filesystem factory for protocol "file" cannot create filesystem for protocol "memory".'); + + (new NativeLocalFilesystemFactory())->create(new Protocol('memory'), []); + } + + public function test_throws_on_unknown_options() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Filesystem factory for protocol "file" does not accept any options. Unknown keys: [foo, bar].'); + + (new NativeLocalFilesystemFactory())->create(new Protocol('file'), ['foo' => 1, 'bar' => 2]); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/StdoutFilesystemFactoryTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/StdoutFilesystemFactoryTest.php new file mode 100644 index 0000000000..b7826b3286 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/Factory/StdoutFilesystemFactoryTest.php @@ -0,0 +1,41 @@ +create(new Protocol('stdout'), []); + + self::assertSame('stdout', $filesystem->protocol()->name); + } + + public function test_protocol_returns_expected_name() : void + { + self::assertSame('stdout', (new StdoutFilesystemFactory())->protocol()->name); + } + + public function test_throws_on_protocol_mismatch() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Filesystem factory for protocol "stdout" cannot create filesystem for protocol "file".'); + + (new StdoutFilesystemFactory())->create(new Protocol('file'), []); + } + + public function test_throws_on_unknown_options() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Filesystem factory for protocol "stdout" does not accept any options. Unknown keys: [foo, bar].'); + + (new StdoutFilesystemFactory())->create(new Protocol('stdout'), ['foo' => 1, 'bar' => 2]); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/FilesystemFactoryRegistryTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/FilesystemFactoryRegistryTest.php new file mode 100644 index 0000000000..42354db8a7 --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/FilesystemFactoryRegistryTest.php @@ -0,0 +1,78 @@ +protocols()); + self::assertFalse($registry->has(new Protocol('file'))); + } + + public function test_get_throws_on_unknown_protocol() : void + { + $registry = new FilesystemFactoryRegistry([new StubFilesystemFactory('memory')]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No filesystem factory registered for protocol "file". Available protocols: [memory].'); + + $registry->get(new Protocol('file')); + } + + public function test_has_returns_false_for_unknown_protocol() : void + { + $registry = new FilesystemFactoryRegistry([new StubFilesystemFactory('memory')]); + + self::assertFalse($registry->has(new Protocol('file'))); + } + + public function test_has_returns_true_for_registered_protocol() : void + { + $registry = new FilesystemFactoryRegistry([new StubFilesystemFactory('memory')]); + + self::assertTrue($registry->has(new Protocol('memory'))); + } + + public function test_protocols_lists_registered_factory_protocols() : void + { + $registry = new FilesystemFactoryRegistry([ + new StubFilesystemFactory('memory'), + new StubFilesystemFactory('file'), + ]); + + self::assertSame(['memory', 'file'], $registry->protocols()); + } + + public function test_returns_factory_by_protocol() : void + { + $memory = new StubFilesystemFactory('memory'); + $local = new StubFilesystemFactory('file'); + + $registry = new FilesystemFactoryRegistry([$memory, $local]); + + self::assertSame($memory, $registry->get(new Protocol('memory'))); + self::assertSame($local, $registry->get(new Protocol('file'))); + } + + public function test_throws_on_duplicate_factory_protocol() : void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Duplicate filesystem factory for protocol "memory".'); + + new FilesystemFactoryRegistry([ + new StubFilesystemFactory('memory'), + new StubFilesystemFactory('memory'), + ]); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/FstabBuilderTelemetryTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/FstabBuilderTelemetryTest.php new file mode 100644 index 0000000000..ebe8f1d22d --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/FstabBuilderTelemetryTest.php @@ -0,0 +1,41 @@ + []], + ); + + self::assertNotInstanceOf(TraceableFilesystem::class, $table->for(new Protocol('memory'))); + } + + public function test_wraps_mounted_filesystems_with_traceable_when_telemetry_enabled() : void + { + $config = new FilesystemTelemetryConfig(telemetry(resource()), new SystemClock(), new FilesystemTelemetryOptions(true, true)); + + $table = FstabBuilder::build( + new FilesystemFactoryRegistry([new MemoryFilesystemFactory()]), + 'default', + ['memory' => []], + $config, + ); + + self::assertInstanceOf(TraceableFilesystem::class, $table->for(new Protocol('memory'))); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/FstabBuilderTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/FstabBuilderTest.php new file mode 100644 index 0000000000..e78ac53a5e --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/Filesystem/FstabBuilderTest.php @@ -0,0 +1,95 @@ +filesystems()); + } + + public function test_mounts_filesystem_from_factory() : void + { + $table = FstabBuilder::build( + new FilesystemFactoryRegistry([new MemoryFilesystemFactory()]), + 'default', + ['memory' => []], + ); + + self::assertSame('memory', $table->for(new Protocol('memory'))->protocol()->name); + } + + public function test_mounts_multiple_filesystems() : void + { + $table = FstabBuilder::build( + new FilesystemFactoryRegistry([ + new NativeLocalFilesystemFactory(), + new MemoryFilesystemFactory(), + ]), + 'default', + [ + 'file' => [], + 'memory' => [], + ], + ); + + self::assertSame('file', $table->for(new Protocol('file'))->protocol()->name); + self::assertSame('memory', $table->for(new Protocol('memory'))->protocol()->name); + } + + public function test_passes_entry_options_to_factory() : void + { + $capturing = new CapturingFilesystemFactory('my-fs', native_local_filesystem()); + + FstabBuilder::build( + new FilesystemFactoryRegistry([$capturing]), + 'default', + [ + 'my-fs' => ['foo' => 'bar'], + ], + ); + + self::assertSame([['protocol' => 'my-fs', 'config' => ['foo' => 'bar']]], $capturing->calls); + } + + public function test_throws_when_factory_missing_for_protocol() : void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Fstab "primary" protocol "memory": No filesystem factory registered for protocol "memory".'); + + FstabBuilder::build( + new FilesystemFactoryRegistry([]), + 'primary', + ['memory' => []], + ); + } + + public function test_throws_when_protocol_name_is_invalid() : void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Fstab "default" protocol "1bad"'); + + FstabBuilder::build( + new FilesystemFactoryRegistry([]), + 'default', + ['1bad' => []], + ); + } +} diff --git a/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/FlowFilesystemBundleTest.php b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/FlowFilesystemBundleTest.php new file mode 100644 index 0000000000..26751d630f --- /dev/null +++ b/src/bridge/symfony/filesystem-bundle/tests/Flow/Bridge/Symfony/FilesystemBundle/Tests/Unit/FlowFilesystemBundleTest.php @@ -0,0 +1,63 @@ +build($container); + + $autoconfigured = $container->getAutoconfiguredAttributes(); + + self::assertArrayHasKey(AsFilesystemFactory::class, $autoconfigured); + + $childDefinition = new ChildDefinition('parent'); + $autoconfigured[AsFilesystemFactory::class]($childDefinition, new AsFilesystemFactory(protocol: 'my-fs'), new \ReflectionClass(\stdClass::class)); + + $tags = $childDefinition->getTag('flow_filesystem.factory'); + self::assertCount(1, $tags); + self::assertSame(['protocol' => 'my-fs'], $tags[0]); + } + + public function test_bundle_alias_resolves_to_flow_filesystem() : void + { + $extension = (new FlowFilesystemBundle())->getContainerExtension(); + + self::assertNotNull($extension); + self::assertSame('flow_filesystem', $extension->getAlias()); + } + + public function test_load_extension_compiles_with_empty_config() : void + { + $bundle = new FlowFilesystemBundle(); + $extension = $bundle->getContainerExtension(); + self::assertNotNull($extension); + + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $container->setParameter('kernel.environment', 'test'); + $container->setParameter('kernel.build_dir', \sys_get_temp_dir()); + $container->registerExtension($extension); + $container->loadFromExtension($extension->getAlias(), [ + 'fstabs' => [ + 'default' => [ + 'filesystems' => [ + 'file' => [], + ], + ], + ], + ]); + $container->compile(); + + self::assertTrue($container->hasExtension('flow_filesystem')); + } +} diff --git a/web/landing/templates/documentation/navigation_right.html.twig b/web/landing/templates/documentation/navigation_right.html.twig index 20637fe6cf..199c5e7b8d 100644 --- a/web/landing/templates/documentation/navigation_right.html.twig +++ b/web/landing/templates/documentation/navigation_right.html.twig @@ -128,6 +128,9 @@