2 Commits

Author SHA1 Message Date
c6963e0574 Merge pull request 'v1.4.1' (#13) from dev into master
Reviewed-on: #13
2023-09-10 07:59:00 +00:00
83794a7464 Merge pull request 'v1.4.0' (#12) from dev into master
Reviewed-on: #12
2023-09-10 07:32:27 +00:00
25 changed files with 341 additions and 1417 deletions

149
README.md
View File

@@ -8,17 +8,14 @@ Without 3rd-party dependencies.
These formats are supported for now: `http`, `curl`, `wget`. These formats are supported for now: `http`, `curl`, `wget`.
> This project has been started and quickly written in my spare time to solve one exact problem in one NDA-project, > This project was quickly written in my spare time to solve one exact problem in one NDA-project, so it may
> so it may contain stupid errors and (for sure) doesn't cover all possible cases according to collection schema. > contain stupid errors and (for sure) doesn't cover all possible cases according to collection schema.
> Feel free to propose your improvements. > So 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 ## Supported features
* collection schemas [**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); * [collection schema **v2.1**](https://schema.postman.com/json/collection/v2.1.0/collection.json);
* `Bearer` auth;
* replace vars in requests by stored in collection and environment file; * 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; * export one or several collections (or even whole directories) into one or all of formats supported at the same time;
* all headers (including disabled for `http`-format); * all headers (including disabled for `http`-format);
@@ -27,9 +24,10 @@ So please always use the latest version of `pm-convert`.
## Planned features ## Planned features
- conversion between postman schema v2.1 <-> v2.0 (#11);
- support as many as possible/necessary of authentication kinds (_currently only `Bearer` supported_); - 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` supported_); - support as many as possible/necessary of body formats (_currently only `json` and `formdata`_);
- documentation generation support (markdown) with response examples (if present) (#6); - documentation generation support (markdown) with responce examples (if present) (#6);
- maybe some another convert formats (like httpie or something...); - maybe some another convert formats (like httpie or something...);
- better logging; - better logging;
- 90%+ test coverage, phpcs, psalm, etc.; - 90%+ test coverage, phpcs, psalm, etc.;
@@ -67,8 +65,8 @@ Possible ARGUMENTS:
-o, --output - a directory OUTPUT_PATH to put results in -o, --output - a directory OUTPUT_PATH to put results in
-e, --env - use environment file with variables to replace in requests -e, --env - use environment file with variables to replace in requests
--var "NAME=VALUE" - force replace specified env variable called NAME with custom VALUE --var "NAME=VALUE" - force replace specified env variable called NAME with custom VALUE
(see interpolation notes below)
-p, --preserve - do not delete OUTPUT_PATH (if exists) -p, --preserve - do not delete OUTPUT_PATH (if exists)
--dump - convert provided arguments into settings file in `pwd`
-h, --help - show this help message and exit -h, --help - show this help message and exit
-v, --version - show version info and exit -v, --version - show version info and exit
@@ -80,26 +78,32 @@ OUTPUT_PATH must be a valid path to writeable directory.
If -o or -e was specified several times then only last one will be used. If -o or -e was specified several times then only last one will be used.
Possible FORMATS: Possible FORMATS:
--http - generate raw *.http files (default) --http - generate raw *.http files (default)
--curl - generate shell scripts with curl command --curl - generate shell scripts with curl command
--wget - generate shell scripts with wget command --wget - generate shell scripts with wget command
--v2.0 - convert from Postman Collection Schema v2.1 into v2.0
--v2.1 - convert from Postman Collection Schema v2.0 into v2.1
-a, --all - convert to all of formats listed above
If no FORMATS specified then --http implied. If no FORMATS specified then --http implied.
Any of FORMATS can be specified at the same time or replaced by --all. Any of FORMATS can be specified at the same time.
Notes about variable interpolation:
1. You can use -e to tell where to find variables to replace in requests.
2. You can use one or several --var to replace specific env variables to your own value.
3. Correct syntax is `--var "NAME=VALUE". NAME may be in curly braces like {{NAME}}.
4. Since -e is optional, a bunch of --var will emulate an environment. Also it does not
matter if there is --var in environment file you provided or not.
5. Even if you (not) provided -e and/or --var, any of variable may still be overridden
from collection (if any), so last ones has top priority.
Example: Example:
./pm-convert \ ./pm-convert \
-f ~/dir1/first.postman_collection.json \ -f ~/dir1/first.postman_collection.json \
--directory ~/team \ --directory ~/team \
--file ~/dir2/second.postman_collection.json \ --file ~/dir2/second.postman_collection.json \
--env ~/localhost.postman_environment.json \ --env ~/localhost.postman_environment.json \
-d ~/personal \ -d ~/personal \
--var "myvar=some value" \ --var "myvar=some value" \
-o ~/postman_export \ -o ~/postman_export
--all
``` ```
### Notices ### Notices
@@ -112,95 +116,12 @@ Example:
If not, you can rename them in Postman or convert collections with similar names into different directories. If not, you can rename them in Postman or convert collections with similar names into different directories.
Otherwise any generated file may be accidently overwritten by another one. Otherwise any generated file may be accidently overwritten by another one.
## Notes about variable interpolation
1. You can use -e to tell where to find variables to replace in requests.
2. You can use one or several --var to replace specific env variables to your own value.
3. Correct syntax is `--var "NAME=VALUE"`. `NAME` may be in curly braces like `{{NAME}}`.
4. Since -e is optional, a bunch of `--var` will emulate an environment. Also it does not matter if there is `--var` in environment file you provided or not.
5. Even if you (not) provided -e and/or `--var`, any of variable may still be overridden from collection (if any), so last ones has top priority.
### Notes about conversion between Postman Schemas
You can use `--v2.1` to convert v2.1 into v2.1 (and this is not a typo).
Same applies to `--v2.0`.
There is a case when a collection has been exported via Postman API.
In such case collection itself places in single root object called `collection` like this:
```
{
"collection": {
// your actual collection here
}
}
```
So, pm-convert will just raise actual data up on top level and write into disk.
## Settings file
You may want to specify parameters once and just use them everytime without explicit defining arguments to `pm-convert`.
This might be done in several ways.
1. Save this file as `pm-convert-settings.json` in your project directory:
```json
{
"directories": [],
"files": [],
"environment": "",
"output": "",
"preserveOutput": false,
"formats": [],
"vars": {}
}
```
Fill it with values you need.
2. Add `--dump` at the end of your command and all arguments you provided will be converted and saved as
`pm-convert-settings.json` in your curent working directory. For example in `--help` file will contain this:
```json
{
"directories": [
"~/team",
"~/personal"
],
"files": [
"~/dir1/first.postman_collection.json",
"~/dir2/second.postman_collection.json"
],
"environment": "~/localhost.postman_environment.json",
"output": "~/postman_export",
"preserveOutput": false,
"formats": [
"http",
"curl",
"wget",
"v2.0",
"v2.1"
],
"vars": {
"myvar": "some value"
}
}
```
If settings file already exists then you will be asked what to do: overwrite it, back it up or exit.
Once settings file saved in current you can just run `pm-convert`.
Settings will be applied like if you pass them explicitly via arguments.
## How to implement a new format ## How to implement a new format
1. Create new namespace in `./src/Converters` and name it according to format of your choice. 1. Create new namespace in `./src/Converters` and name it according to format of your choice
2. Create two classes for converter and request object which extends `Converters\Abstract\Abstract{Converter, Request}` respectively. 2. Create two classes for converter and request object which extends `Converters\Abstract\Abstract{Converter, Request}` respectively
3. Change constants values in your new request class according to format you want to implement. 3. Change constants values in your new request class according to format you want to implement
4. Add your converter class name in `Converters\ConvertFormat`. 4. Write your own logic in converter's `__toString()` method, write new methods and override abstract ones
5. Write your own logic in converter, write new methods and override abstract ones.
## License ## License

View File

@@ -13,9 +13,7 @@
"keywords": ["postman", "collection", "converter", "http", "wget", "curl", "api", "convert"], "keywords": ["postman", "collection", "converter", "http", "wget", "curl", "api", "convert"],
"require": { "require": {
"php": "^8.1", "php": "^8.1",
"ext-json": "*", "ext-json": "*"
"ext-mbstring": "*",
"ext-readline": "*"
}, },
"bin": ["pm-convert"], "bin": ["pm-convert"],
"autoload": { "autoload": {

View File

@@ -2,8 +2,6 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
const EOL = PHP_EOL;
const DS = DIRECTORY_SEPARATOR;
use PmConverter\Processor; use PmConverter\Processor;
@@ -24,12 +22,12 @@ is_null($file) && throw new RuntimeException('Unable to locate autoload.php file
$processor = new Processor($argv); $processor = new Processor($argv);
try { try {
$processor->handle(); $processor->convert();
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
fwrite(STDERR, sprintf('ERROR: %s%s', $e->getMessage(), EOL)); fwrite(STDERR, sprintf('ERROR: %s%s', $e->getMessage(), PHP_EOL));
print(implode(EOL, Processor::usage())); print(implode(PHP_EOL, $processor->usage()));
die(1); die(1);
} catch (Exception $e) { } catch (Exception $e) {
fwrite(STDERR, sprintf('ERROR: %s%s', $e->getMessage(), EOL)); fwrite(STDERR, sprintf('ERROR: %s%s', $e->getMessage(), PHP_EOL));
die(1); die(1);
} }

View File

@@ -1,172 +0,0 @@
<?php
declare(strict_types = 1);
namespace PmConverter;
use Exception;
use Generator;
use JsonException;
use Stringable;
/**
* Class that describes a request collection
*
* @property array|object $item
* @property object $info
* @property object|null $variable
*/
class Collection implements Stringable
{
public readonly CollectionVersion $version;
/**
* Closed constructor so that we could use factory methods
*
* @param object $json
*/
private function __construct(protected object $json)
{
// specific case when collection has been exported via postman api
if (property_exists($json, 'collection')) {
$json = $json->collection;
}
$this->json = $json;
$this->version = $this->detectVersion();
}
/**
* Factory that creates new Collection from content read from file path
*
* @param string $path
* @return static
* @throws JsonException
*/
public static function fromFile(string $path): static
{
$content = file_get_contents(FileSystem::normalizePath($path));
$json = json_decode($content, flags: JSON_THROW_ON_ERROR);
return new static($json);
}
/**
* @inheritDoc
*/
public function __toString(): string
{
return json_encode($this->json, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* Returns reference to the parsed json structure
*
* @return object
*/
public function &raw(): object
{
return $this->json;
}
/**
* Returns reference to any part of the parsed json structure
*
* @param string $name
* @return mixed
*/
public function &__get(string $name): mixed
{
return $this->json->$name;
}
/**
* Returns collection name
*
* @return string
*/
public function name(): string
{
return $this->json->info->name;
}
/**
* Returns the collection version
*
* @return CollectionVersion
*/
protected function detectVersion(): CollectionVersion
{
return match (true) {
str_contains($this->json->info->schema, '/v2.0.') => CollectionVersion::Version20,
str_contains($this->json->info->schema, '/v2.1.') => CollectionVersion::Version21,
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

@@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace PmConverter;
enum CollectionVersion: string
{
case Version20 = 'v2.0';
case Version21 = 'v2.1';
case Unknown = 'unknown';
}

View File

@@ -5,24 +5,22 @@ declare(strict_types=1);
namespace PmConverter\Converters\Abstract; namespace PmConverter\Converters\Abstract;
use Exception; use Exception;
use Iterator; use PmConverter\Converters\{
use PmConverter\Collection; ConverterContract,
use PmConverter\Converters\RequestContract; RequestContract};
use PmConverter\Environment; use PmConverter\Environment;
use PmConverter\Exceptions\CannotCreateDirectoryException;
use PmConverter\Exceptions\DirectoryIsNotWriteableException;
use PmConverter\Exceptions\InvalidHttpVersionException; use PmConverter\Exceptions\InvalidHttpVersionException;
use PmConverter\FileSystem; use PmConverter\FileSystem;
/** /**
* *
*/ */
abstract class AbstractConverter abstract class AbstractConverter implements ConverterContract
{ {
/** /**
* @var Collection|null * @var object|null
*/ */
protected ?Collection $collection = null; protected ?object $collection = null;
/** /**
* @var string * @var string
@@ -30,66 +28,35 @@ abstract class AbstractConverter
protected string $outputPath; protected string $outputPath;
/** /**
* @var RequestContract[] Converted requests * @var Environment|null
*/ */
protected array $requests = []; protected ?Environment $env = null;
/** /**
* Sets output path * Sets an environment with vars
* *
* @param string $outputPath * @param Environment $env
* @return $this * @return $this
*/ */
public function to(string $outputPath): self public function withEnv(Environment $env): static
{ {
$this->outputPath = sprintf('%s%s%s', $outputPath, DS, static::OUTPUT_DIR); $this->env = $env;
return $this; return $this;
} }
/** /**
* Converts requests from collection * Converts collection requests
* *
* @param Collection $collection
* @return static
* @throws CannotCreateDirectoryException
* @throws DirectoryIsNotWriteableException
* @throws Exception * @throws Exception
*/ */
public function convert(Collection $collection): static public function convert(object $collection, string $outputPath): void
{ {
$outputPath = sprintf('%s%s%s', $outputPath, DIRECTORY_SEPARATOR, static::OUTPUT_DIR);
$this->outputPath = FileSystem::makeDir($outputPath);
$this->collection = $collection; $this->collection = $collection;
$this->outputPath = FileSystem::makeDir($this->outputPath); $this->setVariables();
$this->setCollectionVars(); foreach ($collection->item as $item) {
foreach ($collection->iterate() as $path => $item) { $this->convertItem($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);
} }
} }
@@ -98,10 +65,13 @@ abstract class AbstractConverter
* *
* @return $this * @return $this
*/ */
protected function setCollectionVars(): static protected function setVariables(): static
{ {
foreach ($this->collection?->variable ?? [] as $var) { empty($this->env) && $this->env = new Environment($this->collection?->variable);
Environment::instance()->setCustomVar($var->key, $var->value); if (!empty($this->collection?->variable)) {
foreach ($this->collection->variable as $var) {
$this->env[$var->key] = $var->value;
}
} }
return $this; return $this;
} }
@@ -124,9 +94,31 @@ abstract class AbstractConverter
*/ */
protected function isItemFolder(object $item): bool protected function isItemFolder(object $item): bool
{ {
return !empty($item->item) return !empty($item->item) && empty($item->request);
&& is_array($item->item) }
&& 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(DIRECTORY_SEPARATOR, $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));
}
} }
/** /**
@@ -136,18 +128,17 @@ abstract class AbstractConverter
* @return RequestContract * @return RequestContract
* @throws InvalidHttpVersionException * @throws InvalidHttpVersionException
*/ */
protected function makeRequest(object $item): RequestContract protected function initRequest(object $item): RequestContract
{ {
$request_class = static::REQUEST_CLASS; $request_class = static::REQUEST_CLASS;
/** @var RequestContract $request */ /** @var RequestContract $request */
$request = new $request_class(); $request = new $request_class();
$request->setName($item->name); $request->setName($item->name);
$request->setVersion($this->collection->version);
$request->setHttpVersion(1.1); //TODO http version? $request->setHttpVersion(1.1); //TODO http version?
$request->setDescription($item->request?->description ?? null); $request->setDescription($item->request?->description ?? null);
$request->setVerb($item->request->method); $request->setVerb($item->request->method);
$request->setUrl($item->request->url); $request->setUrl($item->request->url->raw);
$request->setHeaders($item->request->header); $request->setHeaders($item->request->header);
$request->setAuth($item->request?->auth ?? $this->collection?->auth ?? null); $request->setAuth($item->request?->auth ?? $this->collection?->auth ?? null);
if ($item->request->method !== 'GET' && !empty($item->request->body)) { if ($item->request->method !== 'GET' && !empty($item->request->body)) {
@@ -166,9 +157,9 @@ abstract class AbstractConverter
*/ */
protected function writeRequest(RequestContract $request, string $subpath = null): bool protected function writeRequest(RequestContract $request, string $subpath = null): bool
{ {
$filedir = sprintf('%s%s%s', $this->outputPath, DS, $subpath); $filedir = sprintf('%s%s%s', $this->outputPath, DIRECTORY_SEPARATOR, $subpath);
$filedir = FileSystem::makeDir($filedir); $filedir = FileSystem::makeDir($filedir);
$filepath = sprintf('%s%s%s.%s', $filedir, DS, $request->getName(), static::FILE_EXT); $filepath = sprintf('%s%s%s.%s', $filedir, DIRECTORY_SEPARATOR, $request->getName(), static::FILE_EXT);
$content = $this->interpolate((string)$request); $content = $this->interpolate((string)$request);
return file_put_contents($filepath, $content) > 0; return file_put_contents($filepath, $content) > 0;
} }
@@ -181,9 +172,18 @@ abstract class AbstractConverter
*/ */
protected function interpolate(string $content): string protected function interpolate(string $content): string
{ {
$replace = static fn ($a) => Environment::instance()->var($var = $a[0]) ?: $var; if (!$this->env?->hasVars()) {
return Environment::instance()->hasVars() return $content;
? preg_replace_callback('/\{\{.*}}/m', $replace, $content) }
: $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;
} }
} }

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace PmConverter\Converters\Abstract; namespace PmConverter\Converters\Abstract;
use PmConverter\CollectionVersion;
use PmConverter\Converters\RequestContract; use PmConverter\Converters\RequestContract;
use PmConverter\Exceptions\EmptyHttpVerbException; use PmConverter\Exceptions\{
use PmConverter\Exceptions\InvalidHttpVersionException; EmptyHttpVerbException,
use PmConverter\HttpVersion; InvalidHttpVersionException};
use PmConverter\HttpVersions;
use Stringable; use Stringable;
/** /**
@@ -22,9 +22,9 @@ abstract class AbstractRequest implements Stringable, RequestContract
protected string $verb; protected string $verb;
/** /**
* @var object|string URL where to send a request * @var string URL where to send a request
*/ */
protected object|string $url; protected string $url;
/** /**
* @var float HTTP protocol version * @var float HTTP protocol version
@@ -56,23 +56,14 @@ abstract class AbstractRequest implements Stringable, RequestContract
*/ */
protected string $bodymode = 'raw'; protected string $bodymode = 'raw';
protected CollectionVersion $version;
public function setVersion(CollectionVersion $version): static
{
$this->version = $version;
return $this;
}
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function setHttpVersion(float $version): static public function setHttpVersion(float $version): static
{ {
if (!in_array($version, HttpVersion::values())) { if (!in_array($version, HttpVersions::values())) {
throw new InvalidHttpVersionException( throw new InvalidHttpVersionException(
'Only these HTTP versions are supported: ' . implode(', ', HttpVersion::values()) 'Only these HTTP versions are supported: ' . implode(', ', HttpVersions::values())
); );
} }
$this->httpVersion = $version; $this->httpVersion = $version;
@@ -101,7 +92,7 @@ abstract class AbstractRequest implements Stringable, RequestContract
*/ */
public function getName(): string public function getName(): string
{ {
return str_replace(DS, '_', $this->name); return str_replace(DIRECTORY_SEPARATOR, '_', $this->name);
} }
/** /**
@@ -142,7 +133,7 @@ abstract class AbstractRequest implements Stringable, RequestContract
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function setUrl(object|string $url): static public function setUrl(string $url): static
{ {
$this->url = $url; $this->url = $url;
return $this; return $this;
@@ -151,9 +142,9 @@ abstract class AbstractRequest implements Stringable, RequestContract
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function getRawUrl(): string public function getUrl(): string
{ {
return is_object($this->url) ? $this->url->raw : $this->url; return $this->url ?: '<empty url>';
} }
/** /**
@@ -195,11 +186,7 @@ abstract class AbstractRequest implements Stringable, RequestContract
if (!empty($auth)) { if (!empty($auth)) {
switch ($auth->type) { switch ($auth->type) {
case 'bearer': case 'bearer':
$this->setHeader('Authorization', 'Bearer ' . match ($this->version) { $this->setHeader('Authorization', 'Bearer ' . $auth->{$auth->type}[0]->value);
CollectionVersion::Version20 => $auth->{$auth->type}->token,
CollectionVersion::Version21 => $auth->{$auth->type}[0]->value,
default => null
});
break; break;
default: default:
break; break;

View File

@@ -5,39 +5,14 @@ declare(strict_types=1);
namespace PmConverter\Converters; namespace PmConverter\Converters;
use PmConverter\Converters\Curl\CurlConverter; use PmConverter\Converters\{
use PmConverter\Converters\Http\HttpConverter; Curl\CurlConverter,
use PmConverter\Converters\Postman20\Postman20Converter; Http\HttpConverter,
use PmConverter\Converters\Postman21\Postman21Converter; Wget\WgetConverter};
use PmConverter\Converters\Wget\WgetConverter;
enum ConvertFormat: string enum ConvertFormat: string
{ {
case Http = HttpConverter::class; case Http = HttpConverter::class;
case Curl = CurlConverter::class; case Curl = CurlConverter::class;
case Wget = WgetConverter::class; 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,8 @@ declare(strict_types=1);
namespace PmConverter\Converters; namespace PmConverter\Converters;
use PmConverter\Collection;
interface ConverterContract interface ConverterContract
{ {
public function convert(Collection $collection, string $outputPath): void; public function convert(object $collection, string $outputPath): void;
public function getOutputPath(): string; public function getOutputPath(): string;
} }

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace PmConverter\Converters\Curl; namespace PmConverter\Converters\Curl;
use PmConverter\Converters\Abstract\AbstractConverter; use PmConverter\Converters\{
Abstract\AbstractConverter,
ConverterContract};
class CurlConverter extends AbstractConverter class CurlConverter extends AbstractConverter implements ConverterContract
{ {
protected const FILE_EXT = 'sh'; protected const FILE_EXT = 'sh';

View File

@@ -72,12 +72,12 @@ class CurlRequest extends AbstractRequest
"curl \ ", "curl \ ",
"\t--http1.1 \ ", //TODO proto "\t--http1.1 \ ", //TODO proto
"\t--request $this->verb \ ", "\t--request $this->verb \ ",
"\t--location {$this->getRawUrl()} \ ", "\t--location $this->url \ ",
], ],
$this->prepareHeaders(), $this->prepareHeaders(),
$this->prepareBody() $this->prepareBody()
); );
$output[] = rtrim(array_pop($output), '\ '); $output[] = rtrim(array_pop($output), '\ ');
return implode(EOL, array_merge($output, [''])); return implode(PHP_EOL, array_merge($output, ['']));
} }
} }

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace PmConverter\Converters\Http; namespace PmConverter\Converters\Http;
use PmConverter\Converters\Abstract\AbstractConverter; use PmConverter\Converters\{
Abstract\AbstractConverter,
ConverterContract};
class HttpConverter extends AbstractConverter class HttpConverter extends AbstractConverter implements ConverterContract
{ {
protected const FILE_EXT = 'http'; protected const FILE_EXT = 'http';

View File

@@ -5,7 +5,8 @@ declare(strict_types=1);
namespace PmConverter\Converters\Http; namespace PmConverter\Converters\Http;
use PmConverter\Converters\Abstract\AbstractRequest; use PmConverter\Converters\Abstract\AbstractRequest;
use PmConverter\Exceptions\EmptyHttpVerbException; use PmConverter\Exceptions\{
EmptyHttpVerbException};
/** /**
* Class to determine file content with http request format * Class to determine file content with http request format
@@ -28,7 +29,7 @@ class HttpRequest extends AbstractRequest
*/ */
protected function prepareHeaders(): array protected function prepareHeaders(): array
{ {
$output[] = sprintf('%s %s HTTP/%s', $this->getVerb(), $this->getRawUrl(), $this->getHttpVersion()); $output[] = sprintf('%s %s HTTP/%s', $this->getVerb(), $this->getUrl(), $this->getHttpVersion());
foreach ($this->headers as $name => $data) { foreach ($this->headers as $name => $data) {
$output[] = sprintf('%s%s: %s', $data['disabled'] ? '# ' : '', $name, $data['value']); $output[] = sprintf('%s%s: %s', $data['disabled'] ? '# ' : '', $name, $data['value']);
} }
@@ -68,6 +69,6 @@ class HttpRequest extends AbstractRequest
$this->prepareHeaders(), $this->prepareHeaders(),
$this->prepareBody() $this->prepareBody()
); );
return implode(EOL, $output); return implode(PHP_EOL, $output);
} }
} }

View File

@@ -1,135 +0,0 @@
<?php
declare(strict_types=1);
namespace PmConverter\Converters\Postman20;
use PmConverter\Collection;
use PmConverter\CollectionVersion;
use PmConverter\Converters\Abstract\AbstractConverter;
use PmConverter\Exceptions\CannotCreateDirectoryException;
use PmConverter\Exceptions\DirectoryIsNotWriteableException;
use PmConverter\FileSystem;
/**
* Converts Postman Collection v2.1 to v2.0
*/
class Postman20Converter extends AbstractConverter
{
protected const FILE_EXT = 'v20.postman_collection.json';
protected const OUTPUT_DIR = 'pm-v2.0';
/**
* Converts collection requests
*
* @param Collection $collection
* @return static
* @throws CannotCreateDirectoryException
* @throws DirectoryIsNotWriteableException
*/
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) {
$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->outputPath = FileSystem::makeDir($this->outputPath);
$this->writeCollection();
return $this;
}
/**
* Writes converted collection into file
*
* @return bool
* @throws CannotCreateDirectoryException
* @throws DirectoryIsNotWriteableException
*/
protected function writeCollection(): bool
{
$filedir = FileSystem::makeDir($this->outputPath);
$filepath = sprintf('%s%s%s.%s', $filedir, DS, $this->collection->name(), static::FILE_EXT);
return file_put_contents($filepath, $this->collection) > 0;
}
/**
* Changes some requests fields in place
*
* @param mixed $item
* @return void
*/
protected function convertItem(mixed $item): void
{
if ($this->isItemFolder($item)) {
foreach ($item->item as $subitem) {
if ($this->isItemFolder($subitem)) {
$this->convertItem($subitem);
} else {
$this->convertAuth($subitem->request);
$this->convertRequestUrl($subitem->request);
$this->convertResponseUrls($subitem->response);
}
}
} else {
$this->convertAuth($item->request);
$this->convertRequestUrl($item->request);
$this->convertResponseUrls($item->response);
}
}
/**
* Converts auth object from v2.1 to v2.0
*
* @param object $request
* @return void
*/
protected function convertAuth(object $request): void
{
if (empty($request->auth)) {
return;
}
$auth = ['type' => 'noauth'];
$type = strtolower($request->auth->type);
if ($type !== 'noauth') {
foreach ($request->auth->$type as $param) {
$auth[$param->key] = $param->value ?? '';
}
$request->auth->$type = (object)$auth;
}
}
/**
* Converts requests URLs from object v2.1 to string v2.0
*
* @param object $request
* @return void
*/
protected function convertRequestUrl(object $request): void
{
if (is_object($request->url)) {
$request->url = $request->url->raw;
}
}
/**
* Converts URLs response examples from object v2.1 to string v2.0
*
* @param array $responses
* @return void
*/
protected function convertResponseUrls(array $responses): void
{
foreach ($responses as $response) {
if (is_object($response->originalRequest->url)) {
$response->originalRequest->url = $response->originalRequest->url->raw;
}
}
}
}

View File

@@ -1,167 +0,0 @@
<?php
declare(strict_types=1);
namespace PmConverter\Converters\Postman21;
use PmConverter\Collection;
use PmConverter\CollectionVersion;
use PmConverter\Converters\Abstract\AbstractConverter;
use PmConverter\Exceptions\CannotCreateDirectoryException;
use PmConverter\Exceptions\DirectoryIsNotWriteableException;
use PmConverter\FileSystem;
/**
* Converts Postman Collection v2.0 to v2.1
*/
class Postman21Converter extends AbstractConverter
{
protected const FILE_EXT = 'v21.postman_collection.json';
protected const OUTPUT_DIR = 'pm-v2.1';
/**
* Converts collection requests
*
* @param Collection $collection
* @return static
* @throws CannotCreateDirectoryException
* @throws DirectoryIsNotWriteableException
*/
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) {
$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->outputPath = FileSystem::makeDir($this->outputPath);
$this->writeCollection();
return $this;
}
/**
* Writes converted collection into file
*
* @return bool
* @throws CannotCreateDirectoryException
* @throws DirectoryIsNotWriteableException
*/
protected function writeCollection(): bool
{
$filedir = FileSystem::makeDir($this->outputPath);
$filepath = sprintf('%s%s%s.%s', $filedir, DS, $this->collection->name(), static::FILE_EXT);
return file_put_contents($filepath, $this->collection) > 0;
}
/**
* Changes some requests fields in place
*
* @param mixed $item
* @return void
*/
protected function convertItem(mixed $item): void
{
if ($this->isItemFolder($item)) {
foreach ($item->item as $subitem) {
if ($this->isItemFolder($subitem)) {
$this->convertItem($subitem);
} else {
$this->convertAuth($subitem->request);
$this->convertRequestUrl($subitem->request);
$this->convertResponseUrls($subitem->response);
}
}
} else {
$this->convertAuth($item->request);
$this->convertRequestUrl($item->request);
$this->convertResponseUrls($item->response);
}
}
/**
* Converts auth object from v2.0 to v2.1
*
* @param object $request
* @return void
*/
protected function convertAuth(object $request): void
{
if (empty($request->auth)) {
return;
}
$type = $request->auth->type;
if ($type !== 'noauth' && isset($request->auth->$type)) {
$auth = [];
foreach ($request->auth->$type as $key => $value) {
$auth[] = (object)[
'key' => $key,
'value' => $value,
'type' => 'string',
];
}
$request->auth->$type = $auth;
}
}
/**
* Converts requests URLs from string v2.0 to object v2.1
*
* @param object $request
* @return void
*/
protected function convertRequestUrl(object $request): void
{
if (is_string($request->url) && mb_strlen($request->url) > 0) {
$data = array_values(array_filter(explode('/', $request->url))); //TODO URL parsing
if (count($data) === 1) {
$url = [
'raw' => $request->url,
'host' => [$data[0] ?? $request->url],
];
} else {
$url = [
'raw' => $request->url,
'protocol' => str_replace(':', '', $data[0]),
'host' => [$data[1] ?? $request->url],
'path' => array_slice($data, 2),
];
}
$request->url = (object)$url;
}
}
/**
* Converts URLs response examples from string v2.0 to object v2.1
*
* @param array $responses
* @return void
*/
protected function convertResponseUrls(array $responses): void
{
foreach ($responses as $response) {
if (is_string($response->originalRequest->url)) {
$data = array_values(array_filter(explode('/', $response->originalRequest->url))); //TODO URL parsing
if (count($data) === 1) {
$url = [
'raw' => $response->originalRequest->url,
'host' => [$data[0] ?? $response->originalRequest->url],
];
} else {
$url = [
'raw' => $response->originalRequest->url,
'protocol' => str_replace(':', '', $data[0]),
'host' => [$data[1] ?? $response->originalRequest->url],
'path' => array_slice($data, 2),
];
}
$response->originalRequest->url = (object)$url;
}
}
}
}

View File

@@ -86,7 +86,7 @@ interface RequestContract
* *
* @return string * @return string
*/ */
public function getRawUrl(): string; public function getUrl(): string;
/** /**
* Sets headers from collection item to request object * Sets headers from collection item to request object
@@ -116,7 +116,7 @@ interface RequestContract
/** /**
* Sets authorization headers * Sets authorization headers
* *
* @param object $auth * @param object|null $auth
* @return $this * @return $this
*/ */
public function setAuth(object $auth): static; public function setAuth(object $auth): static;

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace PmConverter\Converters\Wget; namespace PmConverter\Converters\Wget;
use PmConverter\Converters\Abstract\AbstractConverter; use PmConverter\Converters\{
Abstract\AbstractConverter,
ConverterContract};
class WgetConverter extends AbstractConverter class WgetConverter extends AbstractConverter implements ConverterContract
{ {
protected const FILE_EXT = 'sh'; protected const FILE_EXT = 'sh';

View File

@@ -77,18 +77,18 @@ class WgetRequest extends AbstractRequest
if ($this->getBodymode() === 'formdata') { if ($this->getBodymode() === 'formdata') {
if ($this->getBody()) { if ($this->getBody()) {
if ($this->getVerb() === 'GET') { if ($this->getVerb() === 'GET') {
$output[] = sprintf("\t%s?%s", $this->getRawUrl(), http_build_query($this->prepareBody())); $output[] = sprintf("\t%s?%s", $this->getUrl(), http_build_query($this->prepareBody()));
} else { } else {
$output[] = sprintf("\t--body-data '%s' \ ", http_build_query($this->prepareBody())); $output[] = sprintf("\t--body-data '%s' \ ", http_build_query($this->prepareBody()));
$output[] = sprintf("\t%s", $this->getRawUrl()); $output[] = sprintf("\t%s", $this->getUrl());
} }
} }
} else { } else {
if ($this->getVerb() !== 'GET') { if ($this->getVerb() !== 'GET') {
$output[] = sprintf("\t--body-data '%s' \ ", implode("\n", $this->prepareBody())); $output[] = sprintf("\t--body-data '%s' \ ", implode("\n", $this->prepareBody()));
$output[] = sprintf("\t%s", $this->getRawUrl()); $output[] = sprintf("\t%s", $this->getUrl());
} }
} }
return implode(EOL, array_merge($output, [''])); return implode(PHP_EOL, array_merge($output, ['']));
} }
} }

View File

@@ -4,138 +4,21 @@ declare(strict_types=1);
namespace PmConverter; namespace PmConverter;
use ArrayAccess; class Environment implements \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 * @var array
*/ */
protected array $ownVars = []; protected array $vars = [];
/** /**
* @var array * @param object $env
*/ */
protected array $customVars = []; public function __construct(protected object $env)
public static function instance(): static
{ {
return static::$instance ??= new static(); foreach ($env->values as $var) {
} $this->vars[static::formatKey($var->key)] = $var->value;
/**
* @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;
} }
/** /**
@@ -145,35 +28,15 @@ class Environment implements ArrayAccess
*/ */
public function hasVars(): bool public function hasVars(): bool
{ {
return !empty($this->ownVars) && !empty($this->customVars); return !empty($this->vars);
} }
/**
* Closed constructor
*/
protected function __construct()
{
}
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function offsetExists(mixed $offset): bool public function offsetExists(mixed $offset): bool
{ {
return array_key_exists(static::formatKey($offset), $this->vars()); return array_key_exists(static::formatKey($offset), $this->vars);
} }
/** /**
@@ -181,7 +44,7 @@ class Environment implements ArrayAccess
*/ */
public function offsetGet(mixed $offset): mixed public function offsetGet(mixed $offset): mixed
{ {
return $this->var($offset); return $this->vars[static::formatKey($offset)] ?? null;
} }
/** /**
@@ -189,7 +52,7 @@ class Environment implements ArrayAccess
*/ */
public function offsetSet(mixed $offset, mixed $value): void public function offsetSet(mixed $offset, mixed $value): void
{ {
$this->customVars[static::formatKey($offset)] = $value; $this->vars[static::formatKey($offset)] = $value;
} }
/** /**
@@ -197,7 +60,7 @@ class Environment implements ArrayAccess
*/ */
public function offsetUnset(mixed $offset): void public function offsetUnset(mixed $offset): void
{ {
unset($this->customVars[static::formatKey($offset)]); unset($this->vars[static::formatKey($offset)]);
} }
/** /**

View File

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

View File

@@ -4,12 +4,11 @@ declare(strict_types=1);
namespace PmConverter; namespace PmConverter;
use Exception; use PmConverter\Exceptions\{
use JsonException; CannotCreateDirectoryException,
use PmConverter\Exceptions\CannotCreateDirectoryException; DirectoryIsNotReadableException,
use PmConverter\Exceptions\DirectoryIsNotReadableException; DirectoryIsNotWriteableException,
use PmConverter\Exceptions\DirectoryIsNotWriteableException; DirectoryNotExistsException};
use PmConverter\Exceptions\DirectoryNotExistsException;
/** /**
* Helper class to work with files and directories * Helper class to work with files and directories
@@ -24,8 +23,8 @@ class FileSystem
*/ */
public static function normalizePath(string $path): string public static function normalizePath(string $path): string
{ {
$path = str_replace('~/', "{$_SERVER['HOME']}/", $path); $path = str_replace('~', $_SERVER['HOME'], $path);
return rtrim($path, DS); return rtrim($path, DIRECTORY_SEPARATOR);
} }
/** /**
@@ -101,7 +100,7 @@ class FileSystem
$path = static::normalizePath($path); $path = static::normalizePath($path);
$records = array_diff(@scandir($path) ?: [], ['.', '..']); $records = array_diff(@scandir($path) ?: [], ['.', '..']);
foreach ($records as &$record) { foreach ($records as &$record) {
$record = sprintf('%s%s%s', $path, DS, $record); $record = sprintf('%s%s%s', $path, DIRECTORY_SEPARATOR, $record);
} }
return $records; return $records;
} }
@@ -111,15 +110,16 @@ class FileSystem
* *
* @param string $path * @param string $path
* @return bool * @return bool
* @throws JsonException
* @throws Exception
*/ */
public static function isCollectionFile(string $path): bool public static function isCollectionFile(string $path): bool
{ {
return (!empty($path = static::normalizePath($path))) $path = static::normalizePath($path);
return !empty($path = trim($path))
&& str_ends_with($path, '.postman_collection.json') && str_ends_with($path, '.postman_collection.json')
&& file_exists($path) && file_exists($path)
&& is_readable($path) && is_readable($path)
&& Collection::detectFileVersion($path) !== CollectionVersion::Unknown; && ($json = json_decode(file_get_contents($path), true))
&& json_last_error() === JSON_ERROR_NONE
&& isset($json['collection']['info']['name']);
} }
} }

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace PmConverter; namespace PmConverter;
enum HttpVersion: string enum HttpVersions: string
{ {
case Version10 = '1.0'; case Version10 = '1.0';
case Version11 = '1.1'; case Version11 = '1.1';

View File

@@ -5,89 +5,112 @@ declare(strict_types=1);
namespace PmConverter; namespace PmConverter;
use Exception; use Exception;
use Generator;
use InvalidArgumentException; use InvalidArgumentException;
use JetBrains\PhpStorm\NoReturn;
use JsonException; use JsonException;
use PmConverter\Converters\Abstract\AbstractConverter; use PmConverter\Converters\{
use PmConverter\Converters\ConverterContract; ConverterContract,
use PmConverter\Converters\ConvertFormat; ConvertFormat};
use PmConverter\Exceptions\CannotCreateDirectoryException; use PmConverter\Exceptions\{
use PmConverter\Exceptions\DirectoryIsNotReadableException; CannotCreateDirectoryException,
use PmConverter\Exceptions\DirectoryIsNotWriteableException; DirectoryIsNotReadableException,
use PmConverter\Exceptions\DirectoryNotExistsException; DirectoryIsNotWriteableException,
use PmConverter\Exceptions\IncorrectSettingsFileException; DirectoryNotExistsException};
/**
* Main class
*/
class Processor class Processor
{ {
/** /**
* Converter version * Converter version
*/ */
public const VERSION = '1.6.0'; public const VERSION = '1.4.1';
/** /**
* @var int Initial timestamp * @var string[] Paths to collection files
*/ */
protected readonly int $initTime; protected array $collectionPaths = [];
/** /**
* @var int Initial RAM usage * @var string Output path where to put results in
*/ */
protected readonly int $initRam; protected string $outputPath;
/** /**
* @var Settings Settings (lol) * @var bool Flag to remove output directories or not before conversion started
*/ */
protected Settings $settings; protected bool $preserveOutput = false;
/** /**
* @var ConverterContract[] Converters will be used for conversion according to chosen formats * @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 = []; protected array $converters = [];
/** /**
* @var bool Do we need to save settings file and exit or not? * @var object[] Collections that will be converted into choosen formats
*/ */
protected bool $needDumpSettings = false; protected array $collections = [];
/**
* @var int Initial timestamp
*/
protected int $initTime;
/**
* @var int Initial RAM usage
*/
protected int $initRam;
/**
* @var string Path to environment file
*/
protected string $envFile;
/** /**
* @var Environment * @var Environment
*/ */
public Environment $env; protected Environment $env;
/** /**
* Constructor * Constructor
* *
* @param array $argv Arguments came from cli * @param array $argv Arguments came from cli
*/ */
public function __construct(protected readonly array $argv) public function __construct(protected array $argv)
{ {
$this->initTime = hrtime(true); $this->initTime = hrtime(true);
$this->initRam = memory_get_usage(true); $this->initRam = memory_get_usage(true);
$this->settings = Settings::init();
$this->env = Environment::instance()
->readFromFile($this->settings->envFilepath())
->setCustomVars($this->settings->vars());
$this->parseArgs();
$this->needDumpSettings && $this->dumpSettingsFile();
} }
/** /**
* Parses an array of arguments came from cli * Parses an array of arguments came from cli
* *
* @return void * @return void
* @throws JsonException
*/ */
protected function parseArgs(): void protected function parseArgs(): void
{ {
if (count($this->argv) < 2) {
die(implode(PHP_EOL, $this->usage()) . PHP_EOL);
}
foreach ($this->argv as $idx => $arg) { foreach ($this->argv as $idx => $arg) {
switch ($arg) { switch ($arg) {
case '-f': case '-f':
case '--file': case '--file':
$this->settings->addFilePath($this->argv[$idx + 1]); $rawpath = $this->argv[$idx + 1];
$normpath = FileSystem::normalizePath($rawpath);
if (!FileSystem::isCollectionFile($normpath)) {
throw new InvalidArgumentException(
sprintf("this is not a valid collection file:%s\t%s %s", PHP_EOL, $arg, $rawpath)
);
}
$this->collectionPaths[] = $this->argv[$idx + 1];
break; break;
case '-o': case '-o':
@@ -95,7 +118,7 @@ class Processor
if (empty($this->argv[$idx + 1])) { if (empty($this->argv[$idx + 1])) {
throw new InvalidArgumentException('-o is required'); throw new InvalidArgumentException('-o is required');
} }
$this->settings->setOutputPath($this->argv[$idx + 1]); $this->outputPath = $this->argv[$idx + 1];
break; break;
case '-d': case '-d':
@@ -103,123 +126,61 @@ class Processor
if (empty($this->argv[$idx + 1])) { if (empty($this->argv[$idx + 1])) {
throw new InvalidArgumentException('a directory path is expected for -d (--dir)'); throw new InvalidArgumentException('a directory path is expected for -d (--dir)');
} }
$this->settings->addDirPath($this->argv[$idx + 1]); $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));
break; break;
case '-e': case '-e':
case '--env': case '--env':
$this->settings->setEnvFilepath($this->argv[$idx + 1]); $this->envFile = FileSystem::normalizePath($this->argv[$idx + 1]);
break; break;
case '-p': case '-p':
case '--preserve': case '--preserve':
$this->settings->setPreserveOutput(true); $this->preserveOutput = true;
break; break;
case '--http': case '--http':
$this->settings->addFormat(ConvertFormat::Http); $this->formats[ConvertFormat::Http->name] = ConvertFormat::Http;
break; break;
case '--curl': case '--curl':
$this->settings->addFormat(ConvertFormat::Curl); $this->formats[ConvertFormat::Curl->name] = ConvertFormat::Curl;
break; break;
case '--wget': case '--wget':
$this->settings->addFormat(ConvertFormat::Wget); $this->formats[ConvertFormat::Wget->name] = ConvertFormat::Wget;
break;
case '--v2.0':
$this->settings->addFormat(ConvertFormat::Postman20);
break;
case '--v2.1':
$this->settings->addFormat(ConvertFormat::Postman21);
break;
case '-a':
case '--all':
foreach (ConvertFormat::cases() as $format) {
$this->settings->addFormat($format);
}
break; break;
case '--var': case '--var':
//TODO split by first equal sign [$var, $value] = explode('=', trim($this->argv[$idx + 1]));
$this->env->setCustomVar(...explode('=', trim($this->argv[$idx + 1]))); $this->vars[$var] = $value;
break;
case '--dev':
$this->settings->setDevMode(true);
break;
case '--dump':
$this->needDumpSettings = true;
break; break;
case '-v': case '-v':
case '--version': case '--version':
die(implode(EOL, $this->version()) . EOL); die(implode(PHP_EOL, $this->version()) . PHP_EOL);
case '-h': case '-h':
case '--help': case '--help':
die(implode(EOL, $this->usage()) . EOL); die(implode(PHP_EOL, $this->usage()) . PHP_EOL);
} }
} }
if (empty($this->settings->collectionPaths())) { if (empty($this->collectionPaths)) {
throw new InvalidArgumentException('there are no collections to convert'); throw new InvalidArgumentException('there are no collections to convert');
} }
if (empty($this->settings->outputPath())) { if (empty($this->outputPath)) {
throw new InvalidArgumentException('-o is required'); throw new InvalidArgumentException('-o is required');
} }
if (empty($this->settings->formats())) { if (empty($this->formats)) {
$this->settings->addFormat(ConvertFormat::Http); $this->formats = [ConvertFormat::Http->name => ConvertFormat::Http];
} }
} }
/**
* Handles input command
*
* @return void
* @throws CannotCreateDirectoryException
* @throws DirectoryIsNotReadableException
* @throws DirectoryIsNotWriteableException
* @throws DirectoryNotExistsException
* @throws JsonException
* @throws IncorrectSettingsFileException
*/
public function handle(): void
{
$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 * Initializes output directory
* *
@@ -229,74 +190,100 @@ class Processor
* @throws DirectoryNotExistsException * @throws DirectoryNotExistsException
* @throws DirectoryIsNotReadableException * @throws DirectoryIsNotReadableException
*/ */
protected function prepareOutputDirectory(): void protected function initOutputDirectory(): void
{ {
if (!$this->settings->isPreserveOutput()) { if (isset($this?->outputPath) && !$this->preserveOutput) {
FileSystem::removeDir($this->settings->outputPath()); FileSystem::removeDir($this->outputPath);
} }
FileSystem::makeDir($this->settings->outputPath()); FileSystem::makeDir($this->outputPath);
} }
/** /**
* Initializes converters according to chosen formats * Initializes converters according to choosen formats
* *
* @return void * @return void
*/ */
protected function initConverters(): void protected function initConverters(): void
{ {
foreach ($this->settings->formats() as $type) { foreach ($this->formats as $type) {
$this->converters[$type->name] = new $type->value($this->settings->isPreserveOutput()); $this->converters[$type->name] = new $type->value($this->preserveOutput);
} }
unset($this->formats); unset($this->formats);
} }
/** /**
* Generates collections from settings * Initializes collection objects
* *
* @return Generator<Collection>
* @throws JsonException * @throws JsonException
*/ */
protected function newCollection(): Generator protected function initCollections(): void
{ {
foreach ($this->settings->collectionPaths() as $collectionPath) { foreach ($this->collectionPaths as $collectionPath) {
yield Collection::fromFile($collectionPath); $content = file_get_contents(FileSystem::normalizePath($collectionPath));
$content = json_decode($content, flags: JSON_THROW_ON_ERROR);
if (!property_exists($content, 'collection') || empty($content?->collection)) {
throw new JsonException("not a valid collection: $collectionPath");
}
$this->collections[$content->collection->info->name] = $content->collection;
} }
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 * Begins a conversion
* *
* @throws JsonException * @throws Exception
*/ */
public function convert(): void public function convert(): void
{ {
$count = count($this->settings->collectionPaths()); $this->parseArgs();
$current = $success = 0; $this->initOutputDirectory();
$collection = null; $this->initConverters();
print(implode(EOL, array_merge($this->version(), $this->copyright())) . EOL . EOL); $this->initCollections();
foreach ($this->newCollection() as $collection) { $this->initEnv();
$count = count($this->collections);
$current = 0;
$success = 0;
print(implode(PHP_EOL, array_merge($this->version(), $this->copyright())) . PHP_EOL . PHP_EOL);
foreach ($this->collections as $collectionName => $collection) {
++$current; ++$current;
printf("Converting '%s' (%d/%d):%s", $collection->name(), $current, $count, EOL); printf("Converting '%s' (%d/%d):%s", $collectionName, $current, $count, PHP_EOL);
foreach ($this->converters as $type => $converter) { foreach ($this->converters as $type => $exporter) {
/** @var AbstractConverter $converter */ printf('> %s%s', strtolower($type), PHP_EOL);
printf('> %s%s', strtolower($type), EOL); $outputPath = sprintf('%s%s%s', $this->outputPath, DIRECTORY_SEPARATOR, $collectionName);
$outputPath = sprintf('%s%s%s', $this->settings->outputPath(), DS, $collection->name()); if (!empty($this->env)) {
try { $exporter->withEnv($this->env);
$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);
if ($this->settings->isDevMode()) {
array_map(static fn ($line) => printf(' %s%s', $line, EOL), $e->getTrace());
}
} }
$exporter->convert($collection, $outputPath);
printf(' OK: %s%s', $exporter->getOutputPath(), PHP_EOL);
} }
print(EOL); print(PHP_EOL);
++$success; ++$success;
} }
unset($this->converters, $type, $converter, $outputPath, $this->collections, $collectionName, $collection); unset($this->converters, $type, $exporter, $outputPath, $this->collections, $collectionName, $collection);
$this->printStats($success, $current); $this->printStats($success, $current);
} }
@@ -310,31 +297,25 @@ class Processor
protected function printStats(int $success, int $count): void protected function printStats(int $success, int $count): void
{ {
$time = (hrtime(true) - $this->initTime) / 1_000_000; $time = (hrtime(true) - $this->initTime) / 1_000_000;
$timeFmt = 'ms';
if ($time > 1000) {
$time /= 1000;
$timeFmt = 'sec';
}
$ram = (memory_get_peak_usage(true) - $this->initRam) / 1024 / 1024; $ram = (memory_get_peak_usage(true) - $this->initRam) / 1024 / 1024;
printf("Converted %d/%d in %.2f $timeFmt using up to %.2f MiB RAM%s", $success, $count, $time, $ram, EOL); printf('Converted %d of %d in %.3f ms using up to %.3f MiB RAM%s', $success, $count, $time, $ram, PHP_EOL);
} }
/** /**
* @return string[] * @return string[]
*/ */
public static function version(): array public function version(): array
{ {
return ['Postman collection converter v' . self::VERSION]; return ["Postman collection converter v" . self::VERSION];
} }
/** /**
* @return string[] * @return string[]
*/ */
public static function copyright(): array public function copyright(): array
{ {
$years = ($year = (int)date('Y')) > 2023 ? "2023 - $year" : $year;
return [ return [
"Anthony Axenov (c) $years, MIT license", 'Anthony Axenov (c) ' . date('Y') . ", MIT license",
'https://git.axenov.dev/anthony/pm-convert' 'https://git.axenov.dev/anthony/pm-convert'
]; ];
} }
@@ -342,9 +323,9 @@ class Processor
/** /**
* @return array * @return array
*/ */
public static function usage(): array public function usage(): array
{ {
return array_merge(static::version(), [ return array_merge($this->version(), [
'Usage:', 'Usage:',
"\t./pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS]", "\t./pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS]",
"\tphp pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS]", "\tphp pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS]",
@@ -357,8 +338,8 @@ class Processor
"\t-o, --output - a directory OUTPUT_PATH to put results in", "\t-o, --output - a directory OUTPUT_PATH to put results in",
"\t-e, --env - use environment file with variables to replace in requests", "\t-e, --env - use environment file with variables to replace in requests",
"\t--var \"NAME=VALUE\" - force replace specified env variable called NAME with custom VALUE", "\t--var \"NAME=VALUE\" - force replace specified env variable called NAME with custom VALUE",
"\t (see interpolation notes below)",
"\t-p, --preserve - do not delete OUTPUT_PATH (if exists)", "\t-p, --preserve - do not delete OUTPUT_PATH (if exists)",
"\t --dump - convert provided arguments into settings file in `pwd",
"\t-h, --help - show this help message and exit", "\t-h, --help - show this help message and exit",
"\t-v, --version - show version info and exit", "\t-v, --version - show version info and exit",
'', '',
@@ -370,15 +351,21 @@ class Processor
'If -o or -e was specified several times then only last one will be used.', 'If -o or -e was specified several times then only last one will be used.',
'', '',
'Possible FORMATS:', 'Possible FORMATS:',
"\t--http - generate raw *.http files (default)", "\t--http - generate raw *.http files (default)",
"\t--curl - generate shell scripts with curl command", "\t--curl - generate shell scripts with curl command",
"\t--wget - generate shell scripts with wget command", "\t--wget - generate shell scripts with wget command",
"\t--v2.0 - convert from Postman Collection Schema v2.1 into v2.0",
"\t--v2.1 - convert from Postman Collection Schema v2.0 into v2.1",
"\t-a, --all - convert to all of formats listed above",
'', '',
'If no FORMATS specified then --http implied.', 'If no FORMATS specified then --http implied.',
'Any of FORMATS can be specified at the same time or replaced by --all.', 'Any of FORMATS can be specified at the same time.',
'',
'Notes about variable interpolation:',
"\t1. You can use -e to tell where to find variables to replace in requests.",
"\t2. You can use one or several --var to replace specific env variables to your own value.",
"\t3. Correct syntax is `--var \"NAME=VALUE\". NAME may be in curly braces like {{NAME}}.",
"\t4. Since -e is optional, a bunch of --var will emulate an environment. Also it does not",
"\t matter if there is --var in environment file you provided or not.",
"\t5. Even if you (not) provided -e and/or --var, any of variable may still be overridden",
"\t from collection (if any), so last ones has top priority.",
'', '',
'Example:', 'Example:',
" ./pm-convert \ ", " ./pm-convert \ ",
@@ -388,9 +375,8 @@ class Processor
" --env ~/localhost.postman_environment.json \ ", " --env ~/localhost.postman_environment.json \ ",
" -d ~/personal \ ", " -d ~/personal \ ",
" --var \"myvar=some value\" \ ", " --var \"myvar=some value\" \ ",
" -o ~/postman_export \ ", " -o ~/postman_export ",
" --all",
"", "",
], static::copyright()); ], $this->copyright());
} }
} }

View File

@@ -1,313 +0,0 @@
<?php
declare(strict_types=1);
namespace PmConverter;
use InvalidArgumentException;
use JsonException;
use PmConverter\Converters\ConvertFormat;
use PmConverter\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

@@ -3,14 +3,15 @@
declare(strict_types=1); declare(strict_types=1);
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use PmConverter\Exceptions\EmptyHttpVerbException; use PmConverter\Exceptions\{
use PmConverter\Exceptions\InvalidHttpVersionException; EmptyHttpVerbException,
InvalidHttpVersionException};
class AbstractRequestTest extends TestCase class AbstractRequestTest extends TestCase
{ {
/** /**
* @covers PmConverter\Converters\Abstract\AbstractRequest * @covers PmConverter\Converters\Abstract\AbstractRequest
* @covers PmConverter\HttpVersion * @covers PmConverter\HttpVersions
* @return void * @return void
* @throws InvalidHttpVersionException * @throws InvalidHttpVersionException
*/ */
@@ -25,7 +26,7 @@ class AbstractRequestTest extends TestCase
/** /**
* @covers PmConverter\Converters\Abstract\AbstractRequest * @covers PmConverter\Converters\Abstract\AbstractRequest
* @covers PmConverter\Converters\Abstract\AbstractRequest::getVerb() * @covers PmConverter\Converters\Abstract\AbstractRequest::getVerb()
* @covers PmConverter\HttpVersion * @covers PmConverter\HttpVersions
* @return void * @return void
* @throws InvalidHttpVersionException * @throws InvalidHttpVersionException
*/ */
@@ -70,7 +71,7 @@ class AbstractRequestTest extends TestCase
/** /**
* @covers PmConverter\Converters\Abstract\AbstractRequest * @covers PmConverter\Converters\Abstract\AbstractRequest
* @covers PmConverter\Converters\Abstract\AbstractRequest::setUrl() * @covers PmConverter\Converters\Abstract\AbstractRequest::setUrl()
* @covers PmConverter\Converters\Abstract\AbstractRequest::getRawUrl() * @covers PmConverter\Converters\Abstract\AbstractRequest::getUrl()
* @return void * @return void
*/ */
public function testUrl(): void public function testUrl(): void
@@ -78,7 +79,7 @@ class AbstractRequestTest extends TestCase
$request = new \PmConverter\Converters\Http\HttpRequest(); $request = new \PmConverter\Converters\Http\HttpRequest();
$request->setUrl('http://localhost'); $request->setUrl('http://localhost');
$this->assertSame('http://localhost', $request->getRawUrl()); $this->assertSame('http://localhost', $request->getUrl());
} }
/** /**