Anthony Axenov 2023-11-12 00:17:25 +08:00
parent 5666702ccc
commit 126fb70e84
Signed by: anthony
GPG Key ID: EA9EC32FF7CCD4EC
20 changed files with 824 additions and 280 deletions

View File

@ -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;

View File

@ -14,7 +14,8 @@
"require": {
"php": "^8.1",
"ext-json": "*",
"ext-mbstring": "*"
"ext-mbstring": "*",
"ext-readline": "*"
},
"bin": ["pm-convert"],
"autoload": {

View File

@ -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));

View File

@ -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);
}
}

View File

@ -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<string, RequestContract>
*/
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;
}
}

View File

@ -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 ?: '<empty 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;

View File

@ -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',
};
}
}

View File

@ -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;
}

View File

@ -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()

View File

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

View File

@ -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;
}
/**

View File

@ -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;
}
/**

View File

@ -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;

View File

@ -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, ['']));

View File

@ -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)]);
}
/**

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace PmConverter\Exceptions;
use Exception;
class IncorrectSettingsFileException extends Exception
{
}

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace PmConverter;
use Exception;
use JsonException;
use PmConverter\Exceptions\{
CannotCreateDirectoryException,
@ -24,7 +25,7 @@ class FileSystem
*/
public static function normalizePath(string $path): string
{
$path = str_replace('~', $_SERVER['HOME'], $path);
$path = str_replace('~/', "{$_SERVER['HOME']}/", $path);
return rtrim($path, DS);
}
@ -112,13 +113,14 @@ class FileSystem
* @param string $path
* @return bool
* @throws JsonException
* @throws Exception
*/
public static function isCollectionFile(string $path): bool
{
return (!empty($path = trim(static::normalizePath($path))))
return (!empty($path = static::normalizePath($path)))
&& str_ends_with($path, '.postman_collection.json')
&& file_exists($path)
&& is_readable($path)
&& Collection::fromFile($path)->version() !== CollectionVersion::Unknown;
&& Collection::detectFileVersion($path) !== CollectionVersion::Unknown;
}
}

View File

@ -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<Collection>
* @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());
}
}

314
src/Settings.php 100644
View File

@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace PmConverter;
use InvalidArgumentException;
use JsonException;
use PmConverter\{
Converters\ConvertFormat,
Exceptions\IncorrectSettingsFileException};
/**
* Class responsible for settings storage and dumping
*/
class Settings
{
/**
* @var string Full path to settings file
*/
protected static string $filepath;
/**
* @var bool Flag to output some debug-specific messages
*/
protected bool $devMode = false;
/**
* @var string[] Paths to collection directories
*/
protected array $directories = [];
/**
* @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 string Path to environment file
*/
protected string $envFilepath = '';
/**
* @return bool
*/
public static function fileExists(): bool
{
return file_exists(self::$filepath);
}
/**
* @return self
* @throws IncorrectSettingsFileException
* @throws JsonException
*/
public static function init(): self
{
$content = '{}';
self::$filepath = sprintf('%s%spm-convert-settings.json', $_SERVER['PWD'], DS);
if (self::fileExists()) {
$content = trim(file_get_contents(self::$filepath));
}
try {
$settings = json_decode($content ?: '{}', flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new IncorrectSettingsFileException('Incorrect settings file: ' . $e->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;
}
}

View File

@ -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());
}
/**