From 126fb70e84f3ac745b2a961cfce2589956072999 Mon Sep 17 00:00:00 2001 From: Anthony Axenov Date: Sun, 12 Nov 2023 00:17:25 +0800 Subject: [PATCH] WIP --- README.md | 14 +- composer.json | 3 +- pm-convert | 4 +- src/Collection.php | 77 ++++- src/Converters/Abstract/AbstractConverter.php | 125 +++---- src/Converters/Abstract/AbstractRequest.php | 26 +- src/Converters/ConvertFormat.php | 22 ++ src/Converters/ConverterContract.php | 10 +- src/Converters/Curl/CurlRequest.php | 2 +- src/Converters/Http/HttpRequest.php | 2 +- .../Postman20/Postman20Converter.php | 22 +- .../Postman21/Postman21Converter.php | 10 +- src/Converters/RequestContract.php | 4 +- src/Converters/Wget/WgetRequest.php | 6 +- src/Environment.php | 161 ++++++++- .../IncorrectSettingsFileException.php | 11 + src/FileSystem.php | 8 +- src/Processor.php | 279 ++++++++-------- src/Settings.php | 314 ++++++++++++++++++ tests/AbstractRequestTest.php | 4 +- 20 files changed, 824 insertions(+), 280 deletions(-) create mode 100644 src/Exceptions/IncorrectSettingsFileException.php create mode 100644 src/Settings.php diff --git a/README.md b/README.md index 638e5aa..d5dcd83 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,17 @@ Without 3rd-party dependencies. These formats are supported for now: `http`, `curl`, `wget`. -> This project was quickly written in my spare time to solve one exact problem in one NDA-project, so it may -> contain stupid errors and (for sure) doesn't cover all possible cases according to collection schema. -> So feel free to propose your improvements. +> This project has been started and quickly written in my spare time to solve one exact problem in one NDA-project, +> so it may contain stupid errors and (for sure) doesn't cover all possible cases according to collection schema. +> Feel free to propose your improvements. + +Versions older than the latest are not supported, only current one is. +If you found an error in old version please ensure if an error you found has been fixed in latest version. +So please always use the latest version of `pm-convert`. ## Supported features -* [collection schema **v2.1**](https://schema.postman.com/json/collection/v2.1.0/collection.json); +* collection schema [**v2.1**](https://schema.postman.com/json/collection/v2.1.0/collection.json) and [**v2.0**](https://schema.postman.com/json/collection/v2.0.0/collection.json); * `Bearer` auth; * replace vars in requests by stored in collection and environment file; * export one or several collections (or even whole directories) into one or all of formats supported at the same time; @@ -25,7 +29,7 @@ These formats are supported for now: `http`, `curl`, `wget`. ## Planned features - support as many as possible/necessary of authentication kinds (_currently only `Bearer` supported_); -- support as many as possible/necessary of body formats (_currently only `json` and `formdata`_); +- support as many as possible/necessary of body formats (_currently only `json` and `formdata` supported_); - documentation generation support (markdown) with response examples (if present) (#6); - maybe some another convert formats (like httpie or something...); - better logging; diff --git a/composer.json b/composer.json index fe966c0..dbd39ee 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "require": { "php": "^8.1", "ext-json": "*", - "ext-mbstring": "*" + "ext-mbstring": "*", + "ext-readline": "*" }, "bin": ["pm-convert"], "autoload": { diff --git a/pm-convert b/pm-convert index cb64cd5..1546575 100755 --- a/pm-convert +++ b/pm-convert @@ -24,10 +24,10 @@ is_null($file) && throw new RuntimeException('Unable to locate autoload.php file $processor = new Processor($argv); try { - $processor->convert(); + $processor->handle(); } catch (InvalidArgumentException $e) { fwrite(STDERR, sprintf('ERROR: %s%s', $e->getMessage(), EOL)); - print(implode(EOL, $processor->usage())); + print(implode(EOL, Processor::usage())); die(1); } catch (Exception $e) { fwrite(STDERR, sprintf('ERROR: %s%s', $e->getMessage(), EOL)); diff --git a/src/Collection.php b/src/Collection.php index 891419b..1428715 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -4,6 +4,8 @@ declare(strict_types = 1); namespace PmConverter; +use Exception; +use Generator; use JsonException; use Stringable; @@ -16,6 +18,8 @@ use Stringable; */ class Collection implements Stringable { + public readonly CollectionVersion $version; + /** * Closed constructor so that we could use factory methods * @@ -24,10 +28,11 @@ class Collection implements Stringable private function __construct(protected object $json) { // specific case when collection has been exported via postman api - if (isset($json->collection)) { + if (property_exists($json, 'collection')) { $json = $json->collection; } $this->json = $json; + $this->version = $this->detectVersion(); } /** @@ -88,7 +93,7 @@ class Collection implements Stringable * * @return CollectionVersion */ - public function version(): CollectionVersion + protected function detectVersion(): CollectionVersion { return match (true) { str_contains($this->json->info->schema, '/v2.0.') => CollectionVersion::Version20, @@ -96,4 +101,72 @@ class Collection implements Stringable default => CollectionVersion::Unknown }; } + + /** + * Returns the collection version from raw file + * + * @param string $filepath + * @return CollectionVersion + * @throws Exception + */ + public static function detectFileVersion(string $filepath): CollectionVersion + { + $handle = fopen($filepath, 'r'); + if ($handle === false) { + throw new Exception("Cannot open file for reading: $filepath"); + } + $content = ''; + // Postman collection files may be HUGE and I don't need to parse + // them here to find value .info.schema field because normally it + // is stored at the beginning of a file, so if it's not then this + // is a user problem, not mine. + while (\mb_strlen($content) <= 2048) { + $content .= fgets($handle, 50); + if (str_contains($content, 'https://schema.getpostman.com/json/collection')) { + if (str_contains($content, '/v2.0.')) { + return CollectionVersion::Version20; + } + if (str_contains($content, '/v2.1.')) { + return CollectionVersion::Version21; + } + } + } + return CollectionVersion::Unknown; + } + + /** + * Iterates over collection request items and returns item associated by its path in folder + * + * @param mixed|null $item + * @return Generator + */ + public function iterate(mixed $item = null): Generator + { + $is_recursive = !is_null($item); + $folder = $is_recursive ? $item : $this->json; + static $dir_tree; + $path = DS . ($is_recursive ? implode(DS, $dir_tree ?? []) : ''); + foreach ($folder->item as $subitem) { + if ($this->isItemFolder($subitem)) { + $dir_tree[] = $subitem->name; + yield from $this->iterate($subitem); + continue; + } + yield $path => $subitem; + } + $is_recursive && array_pop($dir_tree); + } + + /** + * Checks whether item contains another items or not + * + * @param object $item + * @return bool + */ + protected function isItemFolder(object $item): bool + { + return !empty($item->item) + && is_array($item->item) + && empty($item->request); + } } diff --git a/src/Converters/Abstract/AbstractConverter.php b/src/Converters/Abstract/AbstractConverter.php index 669be45..2285bc1 100644 --- a/src/Converters/Abstract/AbstractConverter.php +++ b/src/Converters/Abstract/AbstractConverter.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace PmConverter\Converters\Abstract; use Exception; +use Iterator; use PmConverter\Collection; use PmConverter\Converters\{ ConverterContract, @@ -32,53 +33,66 @@ abstract class AbstractConverter implements ConverterContract protected string $outputPath; /** - * @var Environment|null + * @var RequestContract[] Converted requests */ - protected ?Environment $env = null; + protected array $requests = []; /** - * Sets an environment with vars + * Sets output path * - * @param Environment $env + * @param string $outputPath * @return $this */ - public function withEnv(Environment $env): static + public function to(string $outputPath): self { - $this->env = $env; + $this->outputPath = sprintf('%s%s%s', $outputPath, DS, static::OUTPUT_DIR); return $this; } /** - * Creates a new directory to save a converted collection into - * - * @param string $outputPath - * @return void - * @throws CannotCreateDirectoryException - * @throws DirectoryIsNotWriteableException - */ - protected function prepareOutputDir(string $outputPath): void - { - $outputPath = sprintf('%s%s%s', $outputPath, DS, static::OUTPUT_DIR); - $this->outputPath = FileSystem::makeDir($outputPath); - } - - /** - * Converts collection requests + * Converts requests from collection * * @param Collection $collection - * @param string $outputPath - * @return void + * @return static * @throws CannotCreateDirectoryException * @throws DirectoryIsNotWriteableException * @throws Exception */ - public function convert(Collection $collection, string $outputPath): void + public function convert(Collection $collection): static { - $this->prepareOutputDir($outputPath); $this->collection = $collection; - $this->setVariables(); - foreach ($collection->item as $item) { - $this->convertItem($item); + $this->outputPath = FileSystem::makeDir($this->outputPath); + $this->setCollectionVars(); + foreach ($collection->iterate() as $path => $item) { + // $this->requests[$path][] = $this->makeRequest($item); + $this->writeRequest($this->makeRequest($item), $path); + } + return $this; + } + + /** + * Returns converted requests + * + * @return Iterator + */ + public function converted(): Iterator + { + foreach ($this->requests as $path => $requests) { + foreach ($requests as $request) { + yield $path => $request; + } + } + } + + /** + * Writes requests on disk + * + * @throws Exception + */ + public function flush(): void + { + foreach ($this->converted() as $path => $request) { + $this->writeRequest($request, $path); } } @@ -87,13 +101,10 @@ abstract class AbstractConverter implements ConverterContract * * @return $this */ - protected function setVariables(): static + protected function setCollectionVars(): static { - empty($this->env) && $this->env = new Environment($this->collection?->variable); - if (!empty($this->collection?->variable)) { - foreach ($this->collection->variable as $var) { - $this->env[$var->key] = $var->value; - } + foreach ($this->collection?->variable ?? [] as $var) { + Environment::instance()->setCustomVar($var->key, $var->value); } return $this; } @@ -121,30 +132,6 @@ abstract class AbstractConverter implements ConverterContract && empty($item->request); } - /** - * Converts an item to request object and writes it into file - * - * @throws Exception - */ - protected function convertItem(mixed $item): void - { - if ($this->isItemFolder($item)) { - static $dir_tree; - foreach ($item->item as $subitem) { - $dir_tree[] = $item->name; - $path = implode(DS, $dir_tree); - if ($this->isItemFolder($subitem)) { - $this->convertItem($subitem); - } else { - $this->writeRequest($this->initRequest($subitem), $path); - } - array_pop($dir_tree); - } - } else { - $this->writeRequest($this->initRequest($item)); - } - } - /** * Initialiazes request object to be written in file * @@ -152,17 +139,18 @@ abstract class AbstractConverter implements ConverterContract * @return RequestContract * @throws InvalidHttpVersionException */ - protected function initRequest(object $item): RequestContract + protected function makeRequest(object $item): RequestContract { $request_class = static::REQUEST_CLASS; /** @var RequestContract $request */ $request = new $request_class(); $request->setName($item->name); + $request->setVersion($this->collection->version); $request->setHttpVersion(1.1); //TODO http version? $request->setDescription($item->request?->description ?? null); $request->setVerb($item->request->method); - $request->setUrl($item->request->url->raw); + $request->setUrl($item->request->url); $request->setHeaders($item->request->header); $request->setAuth($item->request?->auth ?? $this->collection?->auth ?? null); if ($item->request->method !== 'GET' && !empty($item->request->body)) { @@ -196,18 +184,9 @@ abstract class AbstractConverter implements ConverterContract */ protected function interpolate(string $content): string { - if (!$this->env?->hasVars()) { - return $content; - } - $matches = []; - if (preg_match_all('/\{\{.*}}/m', $content, $matches, PREG_PATTERN_ORDER) > 0) { - foreach ($matches[0] as $key => $var) { - if (str_contains($content, $var)) { - $content = str_replace($var, $this->env[$var] ?: $var, $content); - unset($matches[0][$key]); - } - } - } - return $content; + $replace = static fn ($a) => Environment::instance()->var($var = $a[0]) ?: $var; + return Environment::instance()->hasVars() + ? preg_replace_callback('/\{\{.*}}/m', $replace, $content) + : $content; } } diff --git a/src/Converters/Abstract/AbstractRequest.php b/src/Converters/Abstract/AbstractRequest.php index 7af094e..9728e55 100644 --- a/src/Converters/Abstract/AbstractRequest.php +++ b/src/Converters/Abstract/AbstractRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace PmConverter\Converters\Abstract; +use PmConverter\CollectionVersion; use PmConverter\Converters\RequestContract; use PmConverter\Exceptions\{ EmptyHttpVerbException, @@ -22,9 +23,9 @@ abstract class AbstractRequest implements Stringable, RequestContract protected string $verb; /** - * @var string URL where to send a request + * @var object|string URL where to send a request */ - protected string $url; + protected object|string $url; /** * @var float HTTP protocol version @@ -56,6 +57,15 @@ abstract class AbstractRequest implements Stringable, RequestContract */ protected string $bodymode = 'raw'; + + protected CollectionVersion $version; + + public function setVersion(CollectionVersion $version): static + { + $this->version = $version; + return $this; + } + /** * @inheritDoc */ @@ -133,7 +143,7 @@ abstract class AbstractRequest implements Stringable, RequestContract /** * @inheritDoc */ - public function setUrl(string $url): static + public function setUrl(object|string $url): static { $this->url = $url; return $this; @@ -142,9 +152,9 @@ abstract class AbstractRequest implements Stringable, RequestContract /** * @inheritDoc */ - public function getUrl(): string + public function getRawUrl(): string { - return $this->url ?: ''; + return is_object($this->url) ? $this->url->raw : $this->url; } /** @@ -186,7 +196,11 @@ abstract class AbstractRequest implements Stringable, RequestContract if (!empty($auth)) { switch ($auth->type) { case 'bearer': - $this->setHeader('Authorization', 'Bearer ' . $auth->{$auth->type}[0]->value); + $this->setHeader('Authorization', 'Bearer ' . match ($this->version) { + CollectionVersion::Version20 => $auth->{$auth->type}->token, + CollectionVersion::Version21 => $auth->{$auth->type}[0]->value, + default => null + }); break; default: break; diff --git a/src/Converters/ConvertFormat.php b/src/Converters/ConvertFormat.php index 3bebb54..5cf5057 100644 --- a/src/Converters/ConvertFormat.php +++ b/src/Converters/ConvertFormat.php @@ -19,4 +19,26 @@ enum ConvertFormat: string case Wget = WgetConverter::class; case Postman20 = Postman20Converter::class; case Postman21 = Postman21Converter::class; + + public static function fromArg(string $arg): self + { + return match ($arg) { + 'http' => ConvertFormat::Http, + 'curl' => ConvertFormat::Curl, + 'wget' => ConvertFormat::Wget, + 'v2.0' => ConvertFormat::Postman20, + 'v2.1' => ConvertFormat::Postman21, + }; + } + + public function toArg(): string + { + return match ($this) { + ConvertFormat::Http => 'http', + ConvertFormat::Curl => 'curl', + ConvertFormat::Wget => 'wget', + ConvertFormat::Postman20 => 'v2.0', + ConvertFormat::Postman21 => 'v2.1', + }; + } } diff --git a/src/Converters/ConverterContract.php b/src/Converters/ConverterContract.php index d8116dc..ee0dc87 100644 --- a/src/Converters/ConverterContract.php +++ b/src/Converters/ConverterContract.php @@ -4,10 +4,12 @@ declare(strict_types=1); namespace PmConverter\Converters; -use PmConverter\Collection; - interface ConverterContract { - public function convert(Collection $collection, string $outputPath): void; - public function getOutputPath(): string; + // public function withEnv(Environment $env): static; + // public function to(string $outputPath): static; + // public function convert(Collection $collection): static; + // public function converted(): mixed; + // public function flush(): mixed; + // public function getOutputPath(): string; } diff --git a/src/Converters/Curl/CurlRequest.php b/src/Converters/Curl/CurlRequest.php index 259d807..ec2c404 100644 --- a/src/Converters/Curl/CurlRequest.php +++ b/src/Converters/Curl/CurlRequest.php @@ -72,7 +72,7 @@ class CurlRequest extends AbstractRequest "curl \ ", "\t--http1.1 \ ", //TODO proto "\t--request $this->verb \ ", - "\t--location $this->url \ ", + "\t--location {$this->getRawUrl()} \ ", ], $this->prepareHeaders(), $this->prepareBody() diff --git a/src/Converters/Http/HttpRequest.php b/src/Converters/Http/HttpRequest.php index 8a304be..e11be40 100644 --- a/src/Converters/Http/HttpRequest.php +++ b/src/Converters/Http/HttpRequest.php @@ -29,7 +29,7 @@ class HttpRequest extends AbstractRequest */ protected function prepareHeaders(): array { - $output[] = sprintf('%s %s HTTP/%s', $this->getVerb(), $this->getUrl(), $this->getHttpVersion()); + $output[] = sprintf('%s %s HTTP/%s', $this->getVerb(), $this->getRawUrl(), $this->getHttpVersion()); foreach ($this->headers as $name => $data) { $output[] = sprintf('%s%s: %s', $data['disabled'] ? '# ' : '', $name, $data['value']); } diff --git a/src/Converters/Postman20/Postman20Converter.php b/src/Converters/Postman20/Postman20Converter.php index c068baa..ff775b9 100644 --- a/src/Converters/Postman20/Postman20Converter.php +++ b/src/Converters/Postman20/Postman20Converter.php @@ -26,25 +26,25 @@ class Postman20Converter extends AbstractConverter implements ConverterContract * Converts collection requests * * @param Collection $collection - * @param string $outputPath - * @return void + * @return static * @throws CannotCreateDirectoryException * @throws DirectoryIsNotWriteableException */ - public function convert(Collection $collection, string $outputPath): void + public function convert(Collection $collection): static { $this->collection = $collection; // if data was exported from API, here is already valid json to // just flush it in file, otherwise we need to convert it deeper - if ($this->collection->version() === CollectionVersion::Version21) { + if ($this->collection->version === CollectionVersion::Version21) { $this->collection->info->schema = str_replace('/v2.1.', '/v2.0.', $this->collection->info->schema); $this->convertAuth($this->collection->raw()); foreach ($this->collection->item as $item) { $this->convertItem($item); } } - $this->prepareOutputDir($outputPath); + $this->outputPath = FileSystem::makeDir($this->outputPath); $this->writeCollection(); + return $this; } /** @@ -97,14 +97,12 @@ class Postman20Converter extends AbstractConverter implements ConverterContract if (empty($request->auth)) { return; } - $type = $request->auth->type; - if ($type !== 'noauth' && is_array($request->auth->$type)) { - $auth = []; - foreach ($request->auth->$type as $param) { - $auth[$param->key] = $param->value; - } - $request->auth->$type = (object)$auth; + $auth = []; + $type = strtolower($request->auth->type); + foreach ($request->auth->$type as $param) { + $auth[$param->key] = $param->value ?? ''; } + $request->auth->$type = (object)$auth; } /** diff --git a/src/Converters/Postman21/Postman21Converter.php b/src/Converters/Postman21/Postman21Converter.php index 73f1b5d..b532c62 100644 --- a/src/Converters/Postman21/Postman21Converter.php +++ b/src/Converters/Postman21/Postman21Converter.php @@ -26,25 +26,25 @@ class Postman21Converter extends AbstractConverter implements ConverterContract * Converts collection requests * * @param Collection $collection - * @param string $outputPath - * @return void + * @return static * @throws CannotCreateDirectoryException * @throws DirectoryIsNotWriteableException */ - public function convert(Collection $collection, string $outputPath): void + public function convert(Collection $collection): static { $this->collection = $collection; // if data was exported from API, here is already valid json to // just flush it in file, otherwise we need to convert it deeper - if ($this->collection->version() === CollectionVersion::Version20) { + if ($this->collection->version === CollectionVersion::Version20) { $this->collection->info->schema = str_replace('/v2.0.', '/v2.1.', $this->collection->info->schema); $this->convertAuth($this->collection->raw()); foreach ($this->collection->item as $item) { $this->convertItem($item); } } - $this->prepareOutputDir($outputPath); + $this->outputPath = FileSystem::makeDir($this->outputPath); $this->writeCollection(); + return $this; } /** diff --git a/src/Converters/RequestContract.php b/src/Converters/RequestContract.php index 424f31e..77ea405 100644 --- a/src/Converters/RequestContract.php +++ b/src/Converters/RequestContract.php @@ -86,7 +86,7 @@ interface RequestContract * * @return string */ - public function getUrl(): string; + public function getRawUrl(): string; /** * Sets headers from collection item to request object @@ -116,7 +116,7 @@ interface RequestContract /** * Sets authorization headers * - * @param object|null $auth + * @param object $auth * @return $this */ public function setAuth(object $auth): static; diff --git a/src/Converters/Wget/WgetRequest.php b/src/Converters/Wget/WgetRequest.php index 5d9f5c7..d0f53cf 100644 --- a/src/Converters/Wget/WgetRequest.php +++ b/src/Converters/Wget/WgetRequest.php @@ -77,16 +77,16 @@ class WgetRequest extends AbstractRequest if ($this->getBodymode() === 'formdata') { if ($this->getBody()) { if ($this->getVerb() === 'GET') { - $output[] = sprintf("\t%s?%s", $this->getUrl(), http_build_query($this->prepareBody())); + $output[] = sprintf("\t%s?%s", $this->getRawUrl(), http_build_query($this->prepareBody())); } else { $output[] = sprintf("\t--body-data '%s' \ ", http_build_query($this->prepareBody())); - $output[] = sprintf("\t%s", $this->getUrl()); + $output[] = sprintf("\t%s", $this->getRawUrl()); } } } else { if ($this->getVerb() !== 'GET') { $output[] = sprintf("\t--body-data '%s' \ ", implode("\n", $this->prepareBody())); - $output[] = sprintf("\t%s", $this->getUrl()); + $output[] = sprintf("\t%s", $this->getRawUrl()); } } return implode(EOL, array_merge($output, [''])); diff --git a/src/Environment.php b/src/Environment.php index ab3bcaf..3fe8bf8 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -4,23 +4,138 @@ declare(strict_types=1); namespace PmConverter; -class Environment implements \ArrayAccess +use ArrayAccess; +use JsonException; + +/** + * + */ +class Environment implements ArrayAccess { + /** + * @var string Path to env file + */ + protected static string $filepath = ''; + + /** + * @var Environment + */ + protected static Environment $instance; + /** * @var array */ - protected array $vars = []; + protected array $ownVars = []; /** - * @param object|null $env + * @var array */ - public function __construct(protected ?object $env) + protected array $customVars = []; + + public static function instance(): static { - if (!empty($env->values)) { - foreach ($env->values as $var) { - $this->vars[static::formatKey($var->key)] = $var->value; - } + return static::$instance ??= new static(); + } + + /** + * @param string $filepath + * @return $this + * @throws JsonException + */ + public function readFromFile(string $filepath): static + { + $content = file_get_contents(static::$filepath = $filepath); + $content = json_decode($content, flags: JSON_THROW_ON_ERROR); //TODO try-catch + $content || throw new JsonException("not a valid environment: $filepath"); + property_exists($content, 'environment') && $content = $content->environment; + if (!property_exists($content, 'id') && !property_exists($content, 'name')) { + throw new JsonException("not a valid environment: $filepath"); } + return $this->setOwnVars($content->values); + } + + /** + * @param array $vars + * @return $this + */ + protected function setOwnVars(array $vars): static + { + foreach ($vars as $key => $value) { + is_object($value) && [$key, $value] = [$value->key, $value->value]; + $this->setOwnVar($key, $value); + } + return $this; + } + + /** + * Sets value to some environment own variable + * + * @param string $name + * @param string $value + * @return $this + */ + protected function setOwnVar(string $name, string $value): static + { + $this->ownVars[static::formatKey($name)] = $value; + return $this; + } + + /** + * @param array $vars + * @return $this + */ + public function setCustomVars(array $vars): static + { + foreach ($vars as $key => $value) { + is_object($value) && [$key, $value] = [$value->key, $value->value]; + $this->setCustomVar($key, $value); + } + return $this; + } + + /** + * Sets value to some environment own variable + * + * @param string $name + * @param string $value + * @return $this + */ + public function setCustomVar(string $name, string $value): static + { + $this->customVars[static::formatKey($name)] = $value; + return $this; + } + + /** + * Returns value of specific variable + * + * @param string $name + * @return mixed + */ + public function var(string $name): mixed + { + $format_key = static::formatKey($name); + return $this->ownVars[$format_key] ?? $this->customVars[$format_key] ?? null; + } + + /** + * Returns array of own and custom variables + * + * @return string[] + */ + public function vars(): array + { + return array_merge($this->ownVars, $this->customVars); + } + + /** + * Returns array of custom variables + * + * @return string[] + */ + public function customVars(): array + { + return $this->customVars; } /** @@ -30,15 +145,35 @@ class Environment implements \ArrayAccess */ public function hasVars(): bool { - return !empty($this->vars); + return !empty($this->ownVars) && !empty($this->customVars); } + + + + + + + + + + + + + /** + * Closed constructor + */ + protected function __construct() + { + } + + /** * @inheritDoc */ public function offsetExists(mixed $offset): bool { - return array_key_exists(static::formatKey($offset), $this->vars); + return array_key_exists(static::formatKey($offset), $this->vars()); } /** @@ -46,7 +181,7 @@ class Environment implements \ArrayAccess */ public function offsetGet(mixed $offset): mixed { - return $this->vars[static::formatKey($offset)] ?? null; + return $this->var($offset); } /** @@ -54,7 +189,7 @@ class Environment implements \ArrayAccess */ public function offsetSet(mixed $offset, mixed $value): void { - $this->vars[static::formatKey($offset)] = $value; + $this->customVars[static::formatKey($offset)] = $value; } /** @@ -62,7 +197,7 @@ class Environment implements \ArrayAccess */ public function offsetUnset(mixed $offset): void { - unset($this->vars[static::formatKey($offset)]); + unset($this->customVars[static::formatKey($offset)]); } /** diff --git a/src/Exceptions/IncorrectSettingsFileException.php b/src/Exceptions/IncorrectSettingsFileException.php new file mode 100644 index 0000000..dfcf034 --- /dev/null +++ b/src/Exceptions/IncorrectSettingsFileException.php @@ -0,0 +1,11 @@ +version() !== CollectionVersion::Unknown; + && Collection::detectFileVersion($path) !== CollectionVersion::Unknown; } } diff --git a/src/Processor.php b/src/Processor.php index a8bab6b..9fd7612 100644 --- a/src/Processor.php +++ b/src/Processor.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace PmConverter; use Exception; +use Generator; use InvalidArgumentException; +use JetBrains\PhpStorm\NoReturn; use JsonException; use PmConverter\Converters\{ ConverterContract, @@ -14,81 +16,55 @@ use PmConverter\Exceptions\{ CannotCreateDirectoryException, DirectoryIsNotReadableException, DirectoryIsNotWriteableException, - DirectoryNotExistsException}; + DirectoryNotExistsException, + IncorrectSettingsFileException}; +/** + * + */ class Processor { /** * Converter version */ - public const VERSION = '1.5.0'; - - /** - * @var string[] Paths to collection files - */ - protected array $collectionPaths = []; - - /** - * @var string Output path where to put results in - */ - protected string $outputPath; - - /** - * @var bool Flag to remove output directories or not before conversion started - */ - protected bool $preserveOutput = false; - - /** - * @var string[] Additional variables - */ - protected array $vars; - - /** - * @var ConvertFormat[] Formats to convert a collections into - */ - protected array $formats; - - /** - * @var ConverterContract[] Converters will be used for conversion according to choosen formats - */ - protected array $converters = []; - - /** - * @var Collection[] Collections that will be converted into choosen formats - */ - protected array $collections = []; + public const VERSION = '2.0.0'; /** * @var int Initial timestamp */ - protected int $initTime; + protected readonly int $initTime; /** * @var int Initial RAM usage */ - protected int $initRam; + protected readonly int $initRam; /** - * @var string Path to environment file + * @var Settings Settings (lol) */ - protected string $envFile; + protected Settings $settings; + + /** + * @var ConverterContract[] Converters will be used for conversion according to chosen formats + */ + protected array $converters = []; + + /** + * @var bool Do we need to save settings file and exit or not? + */ + protected bool $needDumpSettings = false; /** * @var Environment */ - protected Environment $env; - - /** - * @var bool Flag to output some debug-specific messages - */ - protected bool $devMode = false; + public Environment $env; /** * Constructor * * @param array $argv Arguments came from cli */ - public function __construct(protected array $argv) + public function __construct(protected readonly array $argv) { $this->initTime = hrtime(true); $this->initRam = memory_get_usage(true); @@ -102,21 +78,11 @@ class Processor */ protected function parseArgs(): void { - if (count($this->argv) < 2) { - die(implode(EOL, $this->usage()) . EOL); - } foreach ($this->argv as $idx => $arg) { switch ($arg) { case '-f': case '--file': - $rawpath = $this->argv[$idx + 1]; - $normpath = FileSystem::normalizePath($rawpath); - if (!FileSystem::isCollectionFile($normpath)) { - throw new InvalidArgumentException( - sprintf("not a valid collection:%s\t%s %s", EOL, $arg, $rawpath) - ); - } - $this->collectionPaths[] = $this->argv[$idx + 1]; + $this->settings->addFilePath($this->argv[$idx + 1]); break; case '-o': @@ -124,7 +90,7 @@ class Processor if (empty($this->argv[$idx + 1])) { throw new InvalidArgumentException('-o is required'); } - $this->outputPath = $this->argv[$idx + 1]; + $this->settings->setOutputPath($this->argv[$idx + 1]); break; case '-d': @@ -132,54 +98,57 @@ class Processor if (empty($this->argv[$idx + 1])) { throw new InvalidArgumentException('a directory path is expected for -d (--dir)'); } - $rawpath = $this->argv[$idx + 1]; - $files = array_filter( - FileSystem::dirContents($rawpath), - static fn($filename) => FileSystem::isCollectionFile($filename) - ); - $this->collectionPaths = array_unique(array_merge($this?->collectionPaths ?? [], $files)); + $this->settings->addDirPath($this->argv[$idx + 1]); break; case '-e': case '--env': - $this->envFile = FileSystem::normalizePath($this->argv[$idx + 1]); + $this->settings->setEnvFilepath($this->argv[$idx + 1]); break; case '-p': case '--preserve': - $this->preserveOutput = true; + $this->settings->setPreserveOutput(true); break; case '--http': - $this->formats[ConvertFormat::Http->name] = ConvertFormat::Http; + $this->settings->addFormat(ConvertFormat::Http); break; case '--curl': - $this->formats[ConvertFormat::Curl->name] = ConvertFormat::Curl; + $this->settings->addFormat(ConvertFormat::Curl); break; case '--wget': - $this->formats[ConvertFormat::Wget->name] = ConvertFormat::Wget; + $this->settings->addFormat(ConvertFormat::Wget); break; case '--v2.0': - $this->formats[ConvertFormat::Postman20->name] = ConvertFormat::Postman20; + $this->settings->addFormat(ConvertFormat::Postman20); break; case '--v2.1': - $this->formats[ConvertFormat::Postman21->name] = ConvertFormat::Postman21; + $this->settings->addFormat(ConvertFormat::Postman21); break; case '-a': case '--all': foreach (ConvertFormat::cases() as $format) { - $this->formats[$format->name] = $format; + $this->settings->addFormat($format); } break; case '--var': - [$var, $value] = explode('=', trim($this->argv[$idx + 1])); - $this->vars[$var] = $value; + //TODO split by first equal sign + $this->env->setCustomVar(...explode('=', trim($this->argv[$idx + 1]))); + break; + + case '--dev': + $this->settings->setDevMode(true); + break; + + case '--dump': + $this->needDumpSettings = true; break; case '-v': @@ -189,23 +158,69 @@ class Processor case '-h': case '--help': die(implode(EOL, $this->usage()) . EOL); - - case '--dev': - $this->devMode = true; - break; } } - if (empty($this->collectionPaths)) { + if (empty($this->settings->collectionPaths())) { throw new InvalidArgumentException('there are no collections to convert'); } - if (empty($this->outputPath)) { + if (empty($this->settings->outputPath())) { throw new InvalidArgumentException('-o is required'); } - if (empty($this->formats)) { - $this->formats = [ConvertFormat::Http->name => ConvertFormat::Http]; + if (empty($this->settings->formats())) { + $this->settings->addFormat(ConvertFormat::Http); } } + /** + * Handles input command + * + * @return void + * @throws CannotCreateDirectoryException + * @throws DirectoryIsNotReadableException + * @throws DirectoryIsNotWriteableException + * @throws DirectoryNotExistsException + * @throws JsonException + * @throws IncorrectSettingsFileException + */ + public function handle(): void + { + $this->settings = Settings::init(); + $this->parseArgs(); + $this->env = Environment::instance() + ->readFromFile($this->settings->envFilepath()) + ->setCustomVars($this->settings->vars()); + $this->needDumpSettings && $this->dumpSettingsFile(); + $this->prepareOutputDirectory(); + $this->initConverters(); + $this->convert(); + } + + /** + * Writes all settings into file if --dump provided + * + * @return never + */ + #[NoReturn] + protected function dumpSettingsFile(): never + { + $answer = 'o'; + if ($this->settings::fileExists()) { + echo 'Settings file already exists: ' . $this->settings::filepath() . EOL; + echo 'Do you want to (o)verwrite it, (b)ackup it and create new one or (c)ancel (default)?' . EOL; + $answer = strtolower(trim(readline('> '))); + } + if (!in_array($answer, ['o', 'b'])) { + die('Current settings file has not been changed' . EOL); + } + if ($answer === 'b') { + $filepath = $this->settings->backup(); + printf("Settings file has been backed up to file:%s\t%s%s", EOL, $filepath, EOL); + } + $this->settings->dump($this->env->customVars()); + printf("Arguments has been converted into settings file:%s\t%s%s", EOL, $this->settings::filepath(), EOL); + die('Review and edit it if needed.' . EOL); + } + /** * Initializes output directory * @@ -215,100 +230,73 @@ class Processor * @throws DirectoryNotExistsException * @throws DirectoryIsNotReadableException */ - protected function initOutputDirectory(): void + protected function prepareOutputDirectory(): void { - if (isset($this?->outputPath) && !$this->preserveOutput) { - FileSystem::removeDir($this->outputPath); + if (!$this->settings->isPreserveOutput()) { + FileSystem::removeDir($this->settings->outputPath()); } - FileSystem::makeDir($this->outputPath); + FileSystem::makeDir($this->settings->outputPath()); } /** - * Initializes converters according to choosen formats + * Initializes converters according to chosen formats * * @return void */ protected function initConverters(): void { - foreach ($this->formats as $type) { - $this->converters[$type->name] = new $type->value($this->preserveOutput); + foreach ($this->settings->formats() as $type) { + $this->converters[$type->name] = new $type->value($this->settings->isPreserveOutput()); } unset($this->formats); } /** - * Initializes collection objects + * Generates collections from settings * + * @return Generator * @throws JsonException */ - protected function initCollections(): void + protected function newCollection(): Generator { - foreach ($this->collectionPaths as $collectionPath) { - $collection = Collection::fromFile($collectionPath); - $this->collections[$collection->name()] = $collection; + foreach ($this->settings->collectionPaths() as $collectionPath) { + yield Collection::fromFile($collectionPath); } - unset($this->collectionPaths, $content); - } - - /** - * Initializes environment object - * - * @return void - * @throws JsonException - */ - protected function initEnv(): void - { - if (!isset($this->envFile)) { - return; - } - $content = file_get_contents(FileSystem::normalizePath($this->envFile)); - $content = json_decode($content, flags: JSON_THROW_ON_ERROR); - if (!property_exists($content, 'environment') || empty($content?->environment)) { - throw new JsonException("not a valid environment: $this->envFile"); - } - $this->env = new Environment($content->environment); - foreach ($this->vars as $var => $value) { - $this->env[$var] = $value; - } - unset($this->vars, $this->envFile, $content, $var, $value); } /** * Begins a conversion * - * @throws Exception + * @throws JsonException */ public function convert(): void { - $this->parseArgs(); - $this->initOutputDirectory(); - $this->initConverters(); - $this->initCollections(); - $this->initEnv(); - $count = count($this->collections); + $count = count($this->settings->collectionPaths()); $current = $success = 0; + $collection = null; print(implode(EOL, array_merge($this->version(), $this->copyright())) . EOL . EOL); - foreach ($this->collections as $collectionName => $collection) { + foreach ($this->newCollection() as $collection) { ++$current; - printf("Converting '%s' (%d/%d):%s", $collectionName, $current, $count, EOL); - foreach ($this->converters as $type => $exporter) { + printf("Converting '%s' (%d/%d):%s", $collection->name(), $current, $count, EOL); + foreach ($this->converters as $type => $converter) { printf('> %s%s', strtolower($type), EOL); - $outputPath = sprintf('%s%s%s', $this->outputPath, DS, $collectionName); - if (!empty($this->env)) { - $exporter->withEnv($this->env); - } + $outputPath = sprintf('%s%s%s', $this->settings->outputPath(), DS, $collection->name()); try { - $exporter->convert($collection, $outputPath); - printf(' OK: %s%s', $exporter->getOutputPath(), EOL); + $converter = $converter->to($outputPath); + $converter = $converter->convert($collection); + $converter->flush(); + printf(' OK: %s%s', $converter->getOutputPath(), EOL); } catch (Exception $e) { printf(' ERROR %s: %s%s', $e->getCode(), $e->getMessage(), EOL); - $this->devMode && array_map(static fn ($line) => printf(" %s%s", $line, EOL), $e->getTrace()); + // if ($this->settings->isDevMode()) { + // array_map(static fn ($line) => printf(' %s%s', $line, EOL), $e->getTrace()); + // } } } print(EOL); ++$success; } - unset($this->converters, $type, $exporter, $outputPath, $this->collections, $collectionName, $collection); + unset($this->converters, $type, $converter, $outputPath, $this->collections, $collectionName, $collection); $this->printStats($success, $current); } @@ -334,18 +322,19 @@ class Processor /** * @return string[] */ - public function version(): array + public static function version(): array { - return ["Postman collection converter v" . self::VERSION]; + return ['Postman collection converter v' . self::VERSION]; } /** * @return string[] */ - public function copyright(): array + public static function copyright(): array { + $years = ($year = (int)date('Y')) > 2023 ? "2023 - $year" : $year; return [ - 'Anthony Axenov (c) ' . date('Y') . ", MIT license", + "Anthony Axenov (c) $years, MIT license", 'https://git.axenov.dev/anthony/pm-convert' ]; } @@ -353,9 +342,9 @@ class Processor /** * @return array */ - public function usage(): array + public static function usage(): array { - return array_merge($this->version(), [ + return array_merge(static::version(), [ 'Usage:', "\t./pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS]", "\tphp pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS]", @@ -401,6 +390,6 @@ class Processor " -o ~/postman_export \ ", " --all", "", - ], $this->copyright()); + ], static::copyright()); } } diff --git a/src/Settings.php b/src/Settings.php new file mode 100644 index 0000000..efb9e81 --- /dev/null +++ b/src/Settings.php @@ -0,0 +1,314 @@ +getMessage(), $e->getCode()); + } + return new self($settings); + } + + /** + * Returns full path to settings file + * + * @return string + */ + public static function filepath(): string + { + return self::$filepath; + } + + /** + * @param object $settings + * @throws JsonException + */ + protected function __construct(object $settings) + { + foreach ($settings->directories ?? [] as $path) { + $this->addDirPath($path); + } + foreach ($settings->files ?? [] as $path) { + $this->addFilePath($path); + } + $this->setDevMode(!empty($settings->devMode)); + $this->setPreserveOutput(!empty($settings->preserveOutput)); + isset($settings->environment) && $this->setEnvFilepath($settings->environment); + isset($settings->output) && $this->setOutputPath($settings->output); + foreach ($settings->formats ?? [] as $format) { + $this->addFormat(ConvertFormat::fromArg($format)); + } + foreach ($settings->vars ?? [] as $name => $value) { + $this->vars[$name] = $value; + } + } + + /** + * @param string $path + * @return void + * @throws JsonException + */ + public function addDirPath(string $path): void + { + $this->directories = array_unique(array_merge( + $this->directories ?? [], + [FileSystem::normalizePath($path)] + )); + $files = array_filter( + FileSystem::dirContents($path), + static fn ($filename) => FileSystem::isCollectionFile($filename) + ); + $this->collectionPaths = array_unique(array_merge($this->collectionPaths ?? [], $files)); + } + + /** + * @param string $path + * @return void + * @throws JsonException + */ + public function addFilePath(string $path): void + { + $normpath = FileSystem::normalizePath($path); + if (!FileSystem::isCollectionFile($normpath)) { + throw new InvalidArgumentException("not a valid collection: $path"); + } + in_array($path, $this->collectionPaths) || $this->collectionPaths[] = $path; + } + + /** + * @param string $outputPath + * @return void + */ + public function setOutputPath(string $outputPath): void + { + $this->outputPath = $outputPath; + } + + /** + * @param bool $devMode + * @return void + */ + public function setDevMode(bool $devMode): void + { + $this->devMode = $devMode; + } + + /** + * @param ConvertFormat $format + * @return void + */ + public function addFormat(ConvertFormat $format): void + { + $this->formats[$format->name] = $format; + } + + /** + * Returns array of variables + * + * @return string[] + */ + public function vars(): array + { + return $this->vars; + } + + /** + * @param bool $preserveOutput + * @return void + */ + public function setPreserveOutput(bool $preserveOutput): void + { + $this->preserveOutput = $preserveOutput; + } + + /** + * @param string $filepath + * @return void + */ + public function setEnvFilepath(string $filepath): void + { + $this->envFilepath = FileSystem::normalizePath($filepath); + } + + /** + * @return bool + */ + public function isDevMode(): bool + { + return $this->devMode; + } + + /** + * @return string[] + */ + public function collectionPaths(): array + { + return $this->collectionPaths; + } + + /** + * @return string + */ + public function outputPath(): string + { + return $this->outputPath; + } + + /** + * @return bool + */ + public function isPreserveOutput(): bool + { + return $this->preserveOutput; + } + + /** + * @return ConvertFormat[] + */ + public function formats(): array + { + return $this->formats; + } + + /** + * @return string + */ + public function envFilepath(): string + { + return $this->envFilepath; + } + + /** + * Determines fieldset of settings JSON + * + * @return array + */ + public function __serialize(): array + { + return [ + 'dev' => $this->isDevMode(), + 'directories' => $this->directories, + 'files' => $this->collectionPaths(), + 'environment' => $this->envFilepath(), + 'output' => $this->outputPath(), + 'preserve-output' => $this->isPreserveOutput(), + 'formats' => array_values(array_map( + static fn (ConvertFormat $format) => $format->toArg(), + $this->formats(), + )), + 'vars' => $this->vars, + ]; + } + + /** + * Converts settings into JSON format + * + * @return string + */ + public function __toString(): string + { + return json_encode($this->__serialize(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + /** + * Writes settings in JSON format into settings file + * + * @param array $vars + * @return bool + */ + public function dump(array $vars = []): bool + { + count($vars) > 0 && $this->vars = $vars; + return file_put_contents(self::$filepath, (string)$this) > 0; + } + + /** + * Makes a backup file of current settings file + * + * @return string + */ + public function backup(): string + { + copy(self::$filepath, $newfilepath = self::$filepath . '.bak.' . time()); + return $newfilepath; + } +} diff --git a/tests/AbstractRequestTest.php b/tests/AbstractRequestTest.php index 54c9c2d..105a24a 100644 --- a/tests/AbstractRequestTest.php +++ b/tests/AbstractRequestTest.php @@ -71,7 +71,7 @@ class AbstractRequestTest extends TestCase /** * @covers PmConverter\Converters\Abstract\AbstractRequest * @covers PmConverter\Converters\Abstract\AbstractRequest::setUrl() - * @covers PmConverter\Converters\Abstract\AbstractRequest::getUrl() + * @covers PmConverter\Converters\Abstract\AbstractRequest::getRawUrl() * @return void */ public function testUrl(): void @@ -79,7 +79,7 @@ class AbstractRequestTest extends TestCase $request = new \PmConverter\Converters\Http\HttpRequest(); $request->setUrl('http://localhost'); - $this->assertSame('http://localhost', $request->getUrl()); + $this->assertSame('http://localhost', $request->getRawUrl()); } /**