diff --git a/README.md b/README.md
index f718ffc..9c923dd 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,80 @@ $loader = new FlatteningLoader(new StreamWrapperLoader());
$contents = $loader($wsdl);
```
+### CallbackLoader
+
+This loader can be used if you want to have more control about how to load a WSDL.
+It can be used to decorate another loader, add debug statements, apply custom loading logic, ...
+
+```php
+use Soap\Wsdl\Loader\CallbackLoader;
+
+$loader = new CallbackLoader(static function (string $location) use ($loader, $style): string {
+ $style->write('> Loading '.$location . '...');
+
+ $result = $loader($location);
+ $style->writeln(' DONE!');
+
+ return $result;
+})
+
+$contents = $loader($wsdl);
+```
+
+## WSDL CLI Tools
+
+```
+wsdl-tools 1.0.0
+
+Available commands:
+ completion Dump the shell completion script
+ flatten Flatten a remote or local WSDL file into 1 file that contains all includes.
+ help Display help for a command
+ list List commands
+ validate Run validations a (flattened) WSDL file.
+```
+
+### Flattening
+
+```
+./bin/wsdl flatten 'https://your/?wsdl' out.wsdl
+```
+
+This command will download the provided WSDL location.
+If any imports are detected, it will download these as well.
+The final result is stored in a single WSDL file.
+
+### Validating
+
+```
+./bin/wsdl validate out.wsdl
+```
+
+This command performs some basic validations on the provided WSDL file.
+If your WSDL contains any imports, you'll have to flatten the WSDL into a single file first.
+
+### Custom WSDL Loader
+
+By default, all CLI tools use the StreamWrapperLoader.
+All CLI tools have a `--loader=file.php` option that can be used to apply a custom WSDL loader.
+This can be handy if your WSDL is located behind authentication or if you want to get control over the HTTP level.
+
+Example custom PHP loader:
+
+```php
+ [
+ 'method' => 'GET',
+ 'header'=> sprintf('Authorization: Basic %s', base64_encode('username:password')),
+ ],
+ ])
+);
+```
## WSDL Validators
diff --git a/bin/wsdl b/bin/wsdl
new file mode 100755
index 0000000..ef711fa
--- /dev/null
+++ b/bin/wsdl
@@ -0,0 +1,37 @@
+#!/usr/bin/env php
+run();
+})();
diff --git a/composer.json b/composer.json
index 54d3be5..22fb202 100644
--- a/composer.json
+++ b/composer.json
@@ -19,6 +19,12 @@
"email": "toonverwerft@gmail.com"
}
],
+ "bin": [
+ "bin/wsdl"
+ ],
+ "config": {
+ "sort-packages": true
+ },
"require": {
"php": "^8.0",
"ext-dom": "*",
@@ -26,9 +32,11 @@
"league/uri": "^6.5",
"league/uri-components": "^2.4",
"php-soap/xml": "^1.2",
+ "symfony/console": "^5.4|^6.0",
"veewee/xml": "~1.2"
},
"require-dev": {
- "phpunit/phpunit": "^9.5"
+ "phpunit/phpunit": "^9.5",
+ "psalm/plugin-symfony": "^3.1"
}
}
diff --git a/psalm.xml b/psalm.xml
index 8d4bba4..4995684 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -14,14 +14,17 @@
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
>
-
+
-
-
+
+
-
-
+
+
+
+
+
diff --git a/src/Console/AppFactory.php b/src/Console/AppFactory.php
new file mode 100644
index 0000000..3d3ac3f
--- /dev/null
+++ b/src/Console/AppFactory.php
@@ -0,0 +1,24 @@
+addCommands([
+ new Command\FlattenCommand(),
+ new Command\ValidateCommand(),
+ ]);
+
+ return $app;
+ }
+}
diff --git a/src/Console/Command/FlattenCommand.php b/src/Console/Command/FlattenCommand.php
new file mode 100644
index 0000000..58ef343
--- /dev/null
+++ b/src/Console/Command/FlattenCommand.php
@@ -0,0 +1,77 @@
+setDescription('Flatten a remote or local WSDL file into 1 file that contains all includes.');
+ $this->addArgument('wsdl', InputArgument::REQUIRED, 'Provide the URI of the WSDL you want to flatten');
+ $this->addArgument('output', InputArgument::REQUIRED, 'Define where the file must be written to');
+ $this->addOption('loader', 'l', InputOption::VALUE_REQUIRED, 'Customize the WSDL loader file that will be used');
+ }
+
+ /**
+ * @throws Exception
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $style = new SymfonyStyle($input, $output);
+ $loader = ConfiguredLoader::createFromConfig(
+ $input->getOption('loader'),
+ fn (WsdlLoader $loader) => $this->configureLoader($loader, $style)
+ );
+ $wsdl = $input->getArgument('wsdl');
+ $output = $input->getArgument('output');
+
+ $style->info('Flattening WSDL "'.$wsdl.'"');
+ $style->warning('This can take a while...');
+ $contents = $loader($wsdl);
+
+ $style->info('Downloaded the WSDL. Writing it to "'.$output.'".');
+
+ Filesystem\write_file($output, $contents);
+
+ $style->success('Succesfully flattened your WSDL!');
+
+ return self::SUCCESS;
+ }
+
+ private function configureLoader(WsdlLoader $loader, SymfonyStyle $style): WsdlLoader
+ {
+ return new FlatteningLoader(
+ new CallbackLoader(static function (string $location) use ($loader, $style): string {
+ $style->write('> Loading '.$location . '...');
+
+ $result = $loader($location);
+ $style->writeln(' DONE!');
+
+ return $result;
+ })
+ );
+ }
+}
diff --git a/src/Console/Command/ValidateCommand.php b/src/Console/Command/ValidateCommand.php
new file mode 100644
index 0000000..87433cd
--- /dev/null
+++ b/src/Console/Command/ValidateCommand.php
@@ -0,0 +1,95 @@
+setDescription('Run validations a (flattened) WSDL file.');
+ $this->addArgument('wsdl', InputArgument::REQUIRED, 'Provide the URI of the WSDL you want to validate');
+ $this->addOption('loader', 'l', InputOption::VALUE_REQUIRED, 'Customize the WSDL loader file that will be used');
+ }
+
+ /**
+ * @throws Exception
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $style = new SymfonyStyle($input, $output);
+ $loader = ConfiguredLoader::createFromConfig($input->getOption('loader'));
+ $wsdl = $input->getArgument('wsdl');
+
+ $style->info('Loading "'.$wsdl.'"...');
+ $document = Document::fromXmlString($loader($wsdl));
+ $xpath = $document->xpath(new WsdlPreset($document));
+
+ $result = $this->runValidationStage(
+ $style,
+ 'Validating WSDL syntax',
+ static fn () => $document->validate(new Validator\WsdlSyntaxValidator())
+ );
+
+ $result = $result && $this->runValidationStage(
+ $style,
+ 'Validating XSD types...',
+ static function () use ($style, $document, $xpath): ?IssueCollection {
+ $schemas = $xpath->query('//schema:schema');
+ if ($schemas->count() !== 1) {
+ $style->warning('Skipped : XSD types can only be validated if there is one schema element.');
+ return null;
+ }
+
+ return $document->validate(new Validator\SchemaSyntaxValidator());
+ }
+ );
+
+ return $result ? self::SUCCESS : self::FAILURE;
+ }
+
+ /**
+ * @param callable(): ?IssueCollection $validator
+ */
+ private function runValidationStage(SymfonyStyle $style, string $label, callable $validator): bool
+ {
+ $style->info($label.'...');
+ $issues = $validator();
+
+ // Skipped ...
+ if ($issues === null) {
+ return true;
+ }
+
+ if ($issues->count()) {
+ $style->block($issues->toString());
+ $style->error('Validation failed!');
+ return false;
+ }
+
+ $style->success('All good!');
+ return true;
+ }
+}
diff --git a/src/Console/Helper/ConfiguredLoader.php b/src/Console/Helper/ConfiguredLoader.php
new file mode 100644
index 0000000..3f8caad
--- /dev/null
+++ b/src/Console/Helper/ConfiguredLoader.php
@@ -0,0 +1,39 @@
+assert($included);
+ }
+
+ return $configurator ? $configurator($loader) : $loader;
+ }
+}
diff --git a/src/Loader/CallbackLoader.php b/src/Loader/CallbackLoader.php
new file mode 100644
index 0000000..e13112a
--- /dev/null
+++ b/src/Loader/CallbackLoader.php
@@ -0,0 +1,37 @@
+callback = $callback;
+ }
+
+ /**
+ * @throws UnloadableWsdlException
+ */
+ public function __invoke(string $location): string
+ {
+ try {
+ return ($this->callback)($location);
+ } catch (UnloadableWsdlException $e) {
+ throw $e;
+ } catch (Exception $e) {
+ throw UnloadableWsdlException::fromException($e);
+ }
+ }
+}
diff --git a/tests/Unit/Console/Helper/ConfiguredLoaderTest.php b/tests/Unit/Console/Helper/ConfiguredLoaderTest.php
new file mode 100644
index 0000000..4591d6a
--- /dev/null
+++ b/tests/Unit/Console/Helper/ConfiguredLoaderTest.php
@@ -0,0 +1,77 @@
+ '');
+ }
+ );
+ static::assertInstanceOf(CallbackLoader::class, $loader);
+ }
+
+
+ public function test_it_can_load_from_file(): void
+ {
+ $this->withLoaderFile(
+ static function (string $file) {
+ $loader = ConfiguredLoader::createFromConfig($file);
+ self::assertSame('loaded', $loader('x'));
+ }
+ );
+ }
+
+
+ public function test_it_can_configure_loaded_from_file(): void
+ {
+ $this->withLoaderFile(
+ static function (string $file) {
+ $loader = ConfiguredLoader::createFromConfig($file, static function ($internal) {
+ self::assertInstanceOf(CallbackLoader::class, $internal);
+
+ return new CallbackLoader(static fn () => 'overwritten');
+ });
+ self::assertSame('overwritten', $loader('x'));
+ },
+ );
+ }
+
+ private function withLoaderFile(callable $execute): void
+ {
+ $file = tempnam(sys_get_temp_dir(), 'wsdlloader');
+ write_file(
+ $file,
+ << 'loaded');
+ EOPHP
+ );
+
+ try {
+ $execute($file);
+ } finally {
+ delete_file($file);
+ }
+ }
+}
diff --git a/tests/Unit/Loader/CallbackLoaderTest.php b/tests/Unit/Loader/CallbackLoaderTest.php
new file mode 100644
index 0000000..54b0d10
--- /dev/null
+++ b/tests/Unit/Loader/CallbackLoaderTest.php
@@ -0,0 +1,40 @@
+ $wsdl);
+ $contents = ($loader)('wsdl');
+
+ static::assertSame('wsdl', $contents);
+ }
+
+ public function test_it_transforms_exceptions(): void
+ {
+ $loader = new CallbackLoader(static fn (string $wsdl): string => throw new Exception('hello'));
+
+ $this->expectException(UnloadableWsdlException::class);
+ $this->expectExceptionMessage('hello');
+ ($loader)('wsdl');
+ }
+
+ public function test_it_does_not_transforms_unloadable_exception(): void
+ {
+ $loader = new CallbackLoader(
+ static fn (string $wsdl): string => throw UnloadableWsdlException::fromLocation($wsdl)
+ );
+
+ $this->expectException(UnloadableWsdlException::class);
+ $this->expectExceptionMessage('file.wsdl');
+ ($loader)('file.wsdl');
+ }
+}