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'); + } +}