13 Commits

Author SHA1 Message Date
593546e3ff Merge pull request 'v1.5.1' (#14) from dev into master
Reviewed-on: #14
2023-09-17 16:42:40 +00:00
771fe4931a Bump version 2023-09-18 00:40:23 +08:00
c6bdcfe7cc New flag --all, corrections in --help and README 2023-09-18 00:39:33 +08:00
60cad4b501 Skip postman schema conversion if it is already same as specified (#10) 2023-09-18 00:33:37 +08:00
01d29ee023 Initial and very naive conversion v2.0 => v2.1 (#10) 2023-09-17 23:59:37 +08:00
3c1871ce1f Conversion to 2.0 (--v2.0), improvements and fixes (#10)
First of all, now you can convert a collection manually exported from Postman UI.

Until this commit, any collection json had to be inside of root 'collection' object. Postman API returns collections in a such way and that was my case. So any collection exported using UI was mistakenly not detected as correct one.

The second thing that it is now possible to convert collections from v2.1 to v2.0 using --v2.0 flag. Even if they are exported via Postman API, of course.

Also some important refactorings are here.
2023-09-17 23:09:19 +08:00
c6963e0574 Merge pull request 'v1.4.1' (#13) from dev into master
Reviewed-on: #13
2023-09-10 07:59:00 +00:00
5c85f23514 Bump version 2023-09-10 15:58:31 +08:00
35e1984326 JSON body & header was fixed 2023-09-10 15:57:39 +08:00
83794a7464 Merge pull request 'v1.4.0' (#12) from dev into master
Reviewed-on: #12
2023-09-10 07:32:27 +00:00
1dc5f7deaf Bump version 2023-09-10 15:31:41 +08:00
0b4317f56a Introducing --var and some improvements in variables interpolation
- now you can override any env variable, see README to find out how --var works
- improvements around env and vars storage
- some tiny ram optimizations in Processor (not sure if it useful nor necessary, actually)
2023-09-10 15:28:09 +08:00
8ab615c062 README misc 2023-09-10 09:17:12 +08:00
15 changed files with 640 additions and 115 deletions

View File

@@ -26,16 +26,17 @@ These formats are supported for now: `http`, `curl`, `wget`.
- 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`_); - support as many as possible/necessary of body formats (_currently only `json` and `formdata`_);
- documentation generation support (markdown) with responce examples (if present); - documentation generation support (markdown) with response 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;
- tests, phpcs, psalm, etc.; - 90%+ test coverage, phpcs, psalm, etc.;
- web version. - web version.
## Install and upgrade ## Install and upgrade
``` ```
composer global r axenov/pm-convert composer global r axenov/pm-convert # install
composer global u axenov/pm-convert # upgrade
``` ```
Make sure your `~/.config/composer/vendor/bin` is in `$PATH` env: Make sure your `~/.config/composer/vendor/bin` is in `$PATH` env:
@@ -58,38 +59,43 @@ Usage:
./vendor/bin/pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS] ./vendor/bin/pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS]
Possible ARGUMENTS: Possible ARGUMENTS:
-f, --file - a PATH to single collection located in PATH to convert from -f, --file - a PATH to a single collection file to convert from
-d, --dir - a directory with collections located in COLLECTION_FILEPATH to convert from -d, --dir - a PATH to a directory with collections to convert from
-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 variable values to replace in request -e, --env - use environment file with variables to replace in requests
-p, --preserve - do not delete OUTPUT_PATH (if exists) --var "NAME=VALUE" - force replace specified env variable called NAME with custom VALUE
-h, --help - show this help message and exit -p, --preserve - do not delete OUTPUT_PATH (if exists)
-v, --version - show version info and exit -h, --help - show this help message and exit
-v, --version - show version info and exit
If no ARGUMENTS passed then --help implied. If no ARGUMENTS passed then --help implied.
If both -f and -d are specified then only unique set of files will be converted. If both -f and -d are specified then only unique set of files from both arguments will be converted.
-f or -d are required to be specified at least once, but each may be specified multiple times. -f or -d are required to be specified at least once, but each may be specified multiple times.
PATH must be a valid path to readable json-file or directory. PATH must be a valid path to readable json-file or directory.
OUTPUT_PATH must be a valid path to writeable directory. OUTPUT_PATH must be a valid path to writeable directory.
If -o is 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.
If -e is specified several times then only last one will be used.
If -e is not specified then only collection vars will be replaced (if any).
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. Any of FORMATS can be specified at the same time or replaced by --all.
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 \
-o ~/postman_export --var "myvar=some value" \
-o ~/postman_export \
--all
``` ```
### Notices ### Notices
@@ -102,12 +108,39 @@ 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.
## 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. Write your own logic in converter's `__toString()` method, write new methods and override abstract ones 4. Add your converter class name in `Converters\ConvertFormat`.
5. Write your own logic in converter, write new methods and override abstract ones.
## License ## License

View File

@@ -13,7 +13,8 @@
"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": "*"
}, },
"bin": ["pm-convert"], "bin": ["pm-convert"],
"autoload": { "autoload": {

99
src/Collection.php Normal file
View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types = 1);
namespace PmConverter;
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
{
/**
* 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 (isset($json->collection)) {
$json = $json->collection;
}
$this->json = $json;
}
/**
* 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
*/
public function version(): 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
};
}
}

12
src/CollectionVersion.php Normal file
View File

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

View File

@@ -5,11 +5,15 @@ declare(strict_types=1);
namespace PmConverter\Converters\Abstract; namespace PmConverter\Converters\Abstract;
use Exception; use Exception;
use PmConverter\Collection;
use PmConverter\Converters\{ use PmConverter\Converters\{
ConverterContract, ConverterContract,
RequestContract}; RequestContract};
use PmConverter\Environment; use PmConverter\Environment;
use PmConverter\Exceptions\InvalidHttpVersionException; use PmConverter\Exceptions\{
CannotCreateDirectoryException,
DirectoryIsNotWriteableException,
InvalidHttpVersionException};
use PmConverter\FileSystem; use PmConverter\FileSystem;
/** /**
@@ -18,20 +22,15 @@ use PmConverter\FileSystem;
abstract class AbstractConverter implements ConverterContract abstract class AbstractConverter implements ConverterContract
{ {
/** /**
* @var object|null * @var Collection|null
*/ */
protected ?object $collection = null; protected ?Collection $collection = null;
/** /**
* @var string * @var string
*/ */
protected string $outputPath; protected string $outputPath;
/**
* @var string[]
*/
protected array $vars = [];
/** /**
* @var Environment|null * @var Environment|null
*/ */
@@ -50,14 +49,32 @@ abstract class AbstractConverter implements ConverterContract
} }
/** /**
* Converts collection requests * Creates a new directory to save a converted collection into
* *
* @throws Exception * @param string $outputPath
* @return void
* @throws CannotCreateDirectoryException
* @throws DirectoryIsNotWriteableException
*/ */
public function convert(object $collection, string $outputPath): void protected function prepareOutputDir(string $outputPath): void
{ {
$outputPath = sprintf('%s%s%s', $outputPath, DIRECTORY_SEPARATOR, static::OUTPUT_DIR); $outputPath = sprintf('%s%s%s', $outputPath, DIRECTORY_SEPARATOR, static::OUTPUT_DIR);
$this->outputPath = FileSystem::makeDir($outputPath); $this->outputPath = FileSystem::makeDir($outputPath);
}
/**
* Converts collection requests
*
* @param Collection $collection
* @param string $outputPath
* @return void
* @throws CannotCreateDirectoryException
* @throws DirectoryIsNotWriteableException
* @throws Exception
*/
public function convert(Collection $collection, string $outputPath): void
{
$this->prepareOutputDir($outputPath);
$this->collection = $collection; $this->collection = $collection;
$this->setVariables(); $this->setVariables();
foreach ($collection->item as $item) { foreach ($collection->item as $item) {
@@ -72,9 +89,10 @@ abstract class AbstractConverter implements ConverterContract
*/ */
protected function setVariables(): static protected function setVariables(): static
{ {
if (isset($this->collection?->variable)) { empty($this->env) && $this->env = new Environment($this->collection?->variable);
if (!empty($this->collection?->variable)) {
foreach ($this->collection->variable as $var) { foreach ($this->collection->variable as $var) {
$this->vars["{{{$var->key}}}"] = $var->value; $this->env[$var->key] = $var->value;
} }
} }
return $this; return $this;
@@ -98,7 +116,9 @@ abstract class AbstractConverter implements ConverterContract
*/ */
protected function isItemFolder(object $item): bool protected function isItemFolder(object $item): bool
{ {
return !empty($item->item) && empty($item->request); return !empty($item->item)
&& is_array($item->item)
&& empty($item->request);
} }
/** /**
@@ -136,19 +156,19 @@ abstract class AbstractConverter implements ConverterContract
{ {
$request_class = static::REQUEST_CLASS; $request_class = static::REQUEST_CLASS;
/** @var RequestContract $result */ /** @var RequestContract $request */
$result = new $request_class(); $request = new $request_class();
$result->setName($item->name); $request->setName($item->name);
$result->setHttpVersion(1.1); //TODO http version? $request->setHttpVersion(1.1); //TODO http version?
$result->setDescription($item->request?->description ?? null); $request->setDescription($item->request?->description ?? null);
$result->setVerb($item->request->method); $request->setVerb($item->request->method);
$result->setUrl($item->request->url->raw); $request->setUrl($item->request->url->raw);
$result->setHeaders($item->request->header); $request->setHeaders($item->request->header);
$result->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)) {
$result->setBody($item->request->body); $request->setBody($item->request->body);
} }
return $result; return $request;
} }
/** /**
@@ -176,21 +196,18 @@ abstract class AbstractConverter implements ConverterContract
*/ */
protected function interpolate(string $content): string protected function interpolate(string $content): string
{ {
if (empty($this->vars) && !$this->env?->hasVars()) { if (!$this->env?->hasVars()) {
return $content; return $content;
} }
$matches = []; $matches = [];
if (preg_match_all('/\{\{[a-zA-Z][a-zA-Z0-9]*}}/m', $content, $matches, PREG_PATTERN_ORDER) > 0) { if (preg_match_all('/\{\{.*}}/m', $content, $matches, PREG_PATTERN_ORDER) > 0) {
foreach ($matches[0] as $key => $var) { foreach ($matches[0] as $key => $var) {
if (str_contains($content, $var)) { if (str_contains($content, $var)) {
$content = str_replace($var, $this->vars[$var] ?? $this->env[$var] ?? $var, $content); $content = str_replace($var, $this->env[$var] ?: $var, $content);
unset($matches[0][$key]); unset($matches[0][$key]);
} }
} }
} }
// if (!empty($matches[0])) {
// fwrite(STDERR, sprintf(' No values found: %s%s', implode(', ', $matches[0]), PHP_EOL));
// }
return $content; return $content;
} }
} }

View File

@@ -8,7 +8,7 @@ use PmConverter\Converters\RequestContract;
use PmConverter\Exceptions\{ use PmConverter\Exceptions\{
EmptyHttpVerbException, EmptyHttpVerbException,
InvalidHttpVersionException}; InvalidHttpVersionException};
use PmConverter\HttpVersions; use PmConverter\HttpVersion;
use Stringable; use Stringable;
/** /**
@@ -61,9 +61,9 @@ abstract class AbstractRequest implements Stringable, RequestContract
*/ */
public function setHttpVersion(float $version): static public function setHttpVersion(float $version): static
{ {
if (!in_array($version, HttpVersions::values())) { if (!in_array($version, HttpVersion::values())) {
throw new InvalidHttpVersionException( throw new InvalidHttpVersionException(
'Only these HTTP versions are supported: ' . implode(', ', HttpVersions::values()) 'Only these HTTP versions are supported: ' . implode(', ', HttpVersion::values())
); );
} }
$this->httpVersion = $version; $this->httpVersion = $version;
@@ -219,11 +219,12 @@ abstract class AbstractRequest implements Stringable, RequestContract
{ {
$this->setBodymode($body->mode); $this->setBodymode($body->mode);
if ($body->mode === 'formdata') { if ($body->mode === 'formdata') {
$this->setHeader('Content-Type', 'multipart/form-data') $this->setHeader('Content-Type', 'multipart/form-data')->setFormdataBody($body);
->setFormdataBody($body); } elseif ($body->mode === 'raw') {
} elseif (!empty($body->options) && $body->options->{$this->bodymode}->language === 'json') { $this->setBodyAsIs($body);
$this->setHeader('Content-Type', 'application/json') if (!empty($body->options) && $body->options->{$this->bodymode}->language === 'json') {
->setJsonBody($body); $this->setHeader('Content-Type', 'application/json');
}
} }
return $this; return $this;
} }
@@ -251,7 +252,7 @@ abstract class AbstractRequest implements Stringable, RequestContract
* @param object $body * @param object $body
* @return $this * @return $this
*/ */
protected function setJsonBody(object $body): static protected function setBodyAsIs(object $body): static
{ {
$this->body = $body->{$this->getBodymode()}; $this->body = $body->{$this->getBodymode()};
return $this; return $this;

View File

@@ -8,6 +8,8 @@ namespace PmConverter\Converters;
use PmConverter\Converters\{ use PmConverter\Converters\{
Curl\CurlConverter, Curl\CurlConverter,
Http\HttpConverter, Http\HttpConverter,
Postman20\Postman20Converter,
Postman21\Postman21Converter,
Wget\WgetConverter}; Wget\WgetConverter};
enum ConvertFormat: string enum ConvertFormat: string
@@ -15,4 +17,6 @@ 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;
} }

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace PmConverter\Converters; namespace PmConverter\Converters;
use PmConverter\Collection;
interface ConverterContract interface ConverterContract
{ {
public function convert(object $collection, string $outputPath): void; public function convert(Collection $collection, string $outputPath): void;
public function getOutputPath(): string; public function getOutputPath(): string;
} }

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace PmConverter\Converters\Postman20;
use PmConverter\Collection;
use PmConverter\CollectionVersion;
use PmConverter\Converters\{
Abstract\AbstractConverter,
ConverterContract};
use PmConverter\Exceptions\CannotCreateDirectoryException;
use PmConverter\Exceptions\DirectoryIsNotWriteableException;
use PmConverter\FileSystem;
/**
* Converts Postman Collection v2.1 to v2.0
*/
class Postman20Converter extends AbstractConverter implements ConverterContract
{
protected const FILE_EXT = 'v20.postman_collection.json';
protected const OUTPUT_DIR = 'pm-v2.0';
/**
* Converts collection requests
*
* @param Collection $collection
* @param string $outputPath
* @return void
* @throws CannotCreateDirectoryException
* @throws DirectoryIsNotWriteableException
*/
public function convert(Collection $collection, string $outputPath): void
{
$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->prepareOutputDir($outputPath);
$this->writeCollection();
}
/**
* 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, DIRECTORY_SEPARATOR, $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;
}
$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;
}
}
/**
* 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

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace PmConverter\Converters\Postman21;
use PmConverter\Collection;
use PmConverter\CollectionVersion;
use PmConverter\Converters\{
Abstract\AbstractConverter,
ConverterContract};
use PmConverter\Exceptions\CannotCreateDirectoryException;
use PmConverter\Exceptions\DirectoryIsNotWriteableException;
use PmConverter\FileSystem;
/**
* Converts Postman Collection v2.0 to v2.1
*/
class Postman21Converter extends AbstractConverter implements ConverterContract
{
protected const FILE_EXT = 'v21.postman_collection.json';
protected const OUTPUT_DIR = 'pm-v2.1';
/**
* Converts collection requests
*
* @param Collection $collection
* @param string $outputPath
* @return void
* @throws CannotCreateDirectoryException
* @throws DirectoryIsNotWriteableException
*/
public function convert(Collection $collection, string $outputPath): void
{
$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->prepareOutputDir($outputPath);
$this->writeCollection();
}
/**
* 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, DIRECTORY_SEPARATOR, $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

@@ -12,12 +12,14 @@ class Environment implements \ArrayAccess
protected array $vars = []; protected array $vars = [];
/** /**
* @param object $env * @param object|null $env
*/ */
public function __construct(protected object $env) public function __construct(protected ?object $env)
{ {
foreach ($env->values as $var) { if (!empty($env->values)) {
$this->vars["{{{$var->key}}}"] = $var->value; foreach ($env->values as $var) {
$this->vars[static::formatKey($var->key)] = $var->value;
}
} }
} }
@@ -36,7 +38,7 @@ class Environment implements \ArrayAccess
*/ */
public function offsetExists(mixed $offset): bool public function offsetExists(mixed $offset): bool
{ {
return array_key_exists($offset, $this->vars); return array_key_exists(static::formatKey($offset), $this->vars);
} }
/** /**
@@ -44,7 +46,7 @@ class Environment implements \ArrayAccess
*/ */
public function offsetGet(mixed $offset): mixed public function offsetGet(mixed $offset): mixed
{ {
return $this->vars[$offset]; return $this->vars[static::formatKey($offset)] ?? null;
} }
/** /**
@@ -52,7 +54,7 @@ class Environment implements \ArrayAccess
*/ */
public function offsetSet(mixed $offset, mixed $value): void public function offsetSet(mixed $offset, mixed $value): void
{ {
$this->vars[$offset] = $value; $this->vars[static::formatKey($offset)] = $value;
} }
/** /**
@@ -60,6 +62,17 @@ class Environment implements \ArrayAccess
*/ */
public function offsetUnset(mixed $offset): void public function offsetUnset(mixed $offset): void
{ {
unset($this->vars[$offset]); unset($this->vars[static::formatKey($offset)]);
}
/**
* Returns correct variable {{name}}
*
* @param string $key
* @return string
*/
public static function formatKey(string $key): string
{
return sprintf('{{%s}}', str_replace(['{', '}'], '', $key));
} }
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace PmConverter; namespace PmConverter;
use JsonException;
use PmConverter\Exceptions\{ use PmConverter\Exceptions\{
CannotCreateDirectoryException, CannotCreateDirectoryException,
DirectoryIsNotReadableException, DirectoryIsNotReadableException,
@@ -110,16 +111,14 @@ class FileSystem
* *
* @param string $path * @param string $path
* @return bool * @return bool
* @throws JsonException
*/ */
public static function isCollectionFile(string $path): bool public static function isCollectionFile(string $path): bool
{ {
$path = static::normalizePath($path); return (!empty($path = trim(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)
&& ($json = json_decode(file_get_contents($path), true)) && Collection::fromFile($path)->version() !== CollectionVersion::Unknown;
&& 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 HttpVersions: string enum HttpVersion: string
{ {
case Version10 = '1.0'; case Version10 = '1.0';
case Version11 = '1.1'; case Version11 = '1.1';

View File

@@ -21,7 +21,7 @@ class Processor
/** /**
* Converter version * Converter version
*/ */
public const VERSION = '1.3.0'; public const VERSION = '1.5.0';
/** /**
* @var string[] Paths to collection files * @var string[] Paths to collection files
@@ -38,6 +38,11 @@ class Processor
*/ */
protected bool $preserveOutput = false; protected bool $preserveOutput = false;
/**
* @var string[] Additional variables
*/
protected array $vars;
/** /**
* @var ConvertFormat[] Formats to convert a collections into * @var ConvertFormat[] Formats to convert a collections into
*/ */
@@ -49,7 +54,7 @@ class Processor
protected array $converters = []; protected array $converters = [];
/** /**
* @var object[] Collections that will be converted into choosen formats * @var Collection[] Collections that will be converted into choosen formats
*/ */
protected array $collections = []; protected array $collections = [];
@@ -64,7 +69,7 @@ class Processor
protected int $initRam; protected int $initRam;
/** /**
* @var string * @var string Path to environment file
*/ */
protected string $envFile; protected string $envFile;
@@ -88,6 +93,7 @@ class Processor
* 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
{ {
@@ -102,7 +108,7 @@ class Processor
$normpath = FileSystem::normalizePath($rawpath); $normpath = FileSystem::normalizePath($rawpath);
if (!FileSystem::isCollectionFile($normpath)) { if (!FileSystem::isCollectionFile($normpath)) {
throw new InvalidArgumentException( throw new InvalidArgumentException(
sprintf("this is not a valid collection file:%s\t%s %s", PHP_EOL, $arg, $rawpath) sprintf("not a valid collection:%s\t%s %s", PHP_EOL, $arg, $rawpath)
); );
} }
$this->collectionPaths[] = $this->argv[$idx + 1]; $this->collectionPaths[] = $this->argv[$idx + 1];
@@ -151,6 +157,26 @@ class Processor
$this->formats[ConvertFormat::Wget->name] = ConvertFormat::Wget; $this->formats[ConvertFormat::Wget->name] = ConvertFormat::Wget;
break; break;
case '--v2.0':
$this->formats[ConvertFormat::Postman20->name] = ConvertFormat::Postman20;
break;
case '--v2.1':
$this->formats[ConvertFormat::Postman21->name] = ConvertFormat::Postman21;
break;
case '-a':
case '--all':
foreach (ConvertFormat::cases() as $format) {
$this->formats[$format->name] = $format;
}
break;
case '--var':
[$var, $value] = explode('=', trim($this->argv[$idx + 1]));
$this->vars[$var] = $value;
break;
case '-v': case '-v':
case '--version': case '--version':
die(implode(PHP_EOL, $this->version()) . PHP_EOL); die(implode(PHP_EOL, $this->version()) . PHP_EOL);
@@ -198,6 +224,7 @@ class Processor
foreach ($this->formats as $type) { foreach ($this->formats as $type) {
$this->converters[$type->name] = new $type->value($this->preserveOutput); $this->converters[$type->name] = new $type->value($this->preserveOutput);
} }
unset($this->formats);
} }
/** /**
@@ -208,13 +235,10 @@ class Processor
protected function initCollections(): void protected function initCollections(): void
{ {
foreach ($this->collectionPaths as $collectionPath) { foreach ($this->collectionPaths as $collectionPath) {
$content = file_get_contents(FileSystem::normalizePath($collectionPath)); $collection = Collection::fromFile($collectionPath);
$content = json_decode($content, flags: JSON_THROW_ON_ERROR); $this->collections[$collection->name()] = $collection;
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);
} }
/** /**
@@ -234,6 +258,10 @@ class Processor
throw new JsonException("not a valid environment: $this->envFile"); throw new JsonException("not a valid environment: $this->envFile");
} }
$this->env = new Environment($content->environment); $this->env = new Environment($content->environment);
foreach ($this->vars as $var => $value) {
$this->env[$var] = $value;
}
unset($this->vars, $this->envFile, $content, $var, $value);
} }
/** /**
@@ -267,6 +295,7 @@ class Processor
print(PHP_EOL); print(PHP_EOL);
++$success; ++$success;
} }
unset($this->converters, $type, $exporter, $outputPath, $this->collections, $collectionName, $collection);
$this->printStats($success, $current); $this->printStats($success, $current);
} }
@@ -280,8 +309,13 @@ 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 of %d in %.3f ms using %.3f MiB RAM%s', $success, $count, $time, $ram, PHP_EOL); printf("Converted %d/%d in %.2f $timeFmt using up to %.2f MiB RAM%s", $success, $count, $time, $ram, PHP_EOL);
} }
/** /**
@@ -316,30 +350,32 @@ class Processor
"\t./vendor/bin/pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS]", "\t./vendor/bin/pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS]",
'', '',
'Possible ARGUMENTS:', 'Possible ARGUMENTS:',
"\t-f, --file - a PATH to single collection located in PATH to convert from", "\t-f, --file - a PATH to a single collection file to convert from",
"\t-d, --dir - a directory with collections located in PATH to convert from", "\t-d, --dir - a PATH to a directory with collections to convert from",
"\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 variable values to replace in request", "\t-e, --env - use environment file with variables to replace in requests",
"\t-p, --preserve - do not delete OUTPUT_PATH (if exists)", "\t--var \"NAME=VALUE\" - force replace specified env variable called NAME with custom VALUE",
"\t-h, --help - show this help message and exit", "\t-p, --preserve - do not delete OUTPUT_PATH (if exists)",
"\t-v, --version - show version info and exit", "\t-h, --help - show this help message and exit",
"\t-v, --version - show version info and exit",
'', '',
'If no ARGUMENTS passed then --help implied.', 'If no ARGUMENTS passed then --help implied.',
'If both -f and -d are specified then only unique set of files will be converted.', 'If both -f and -d are specified then only unique set of files from both arguments will be converted.',
'-f or -d are required to be specified at least once, but each may be specified multiple times.', '-f or -d are required to be specified at least once, but each may be specified multiple times.',
'PATH must be a valid path to readable json-file or directory.', 'PATH must be a valid path to readable json-file or directory.',
'OUTPUT_PATH must be a valid path to writeable directory.', 'OUTPUT_PATH must be a valid path to writeable directory.',
'If -o is 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.',
'If -e is specified several times then only last one will be used.',
'If -e is not specified then only collection vars will be replaced (if any).',
'', '',
'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.', 'Any of FORMATS can be specified at the same time or replaced by --all.',
'', '',
'Example:', 'Example:',
" ./pm-convert \ ", " ./pm-convert \ ",
@@ -348,7 +384,9 @@ class Processor
" --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 \ ",
" -o ~/postman_export ", " --var \"myvar=some value\" \ ",
" -o ~/postman_export \ ",
" --all",
"", "",
], $this->copyright()); ], $this->copyright());
} }

View File

@@ -11,7 +11,7 @@ class AbstractRequestTest extends TestCase
{ {
/** /**
* @covers PmConverter\Converters\Abstract\AbstractRequest * @covers PmConverter\Converters\Abstract\AbstractRequest
* @covers PmConverter\HttpVersions * @covers PmConverter\HttpVersion
* @return void * @return void
* @throws InvalidHttpVersionException * @throws InvalidHttpVersionException
*/ */
@@ -26,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\HttpVersions * @covers PmConverter\HttpVersion
* @return void * @return void
* @throws InvalidHttpVersionException * @throws InvalidHttpVersionException
*/ */
@@ -215,7 +215,7 @@ class AbstractRequestTest extends TestCase
* @covers PmConverter\Converters\Abstract\AbstractRequest::setBodymode() * @covers PmConverter\Converters\Abstract\AbstractRequest::setBodymode()
* @covers PmConverter\Converters\Abstract\AbstractRequest::setHeader() * @covers PmConverter\Converters\Abstract\AbstractRequest::setHeader()
* @covers PmConverter\Converters\Abstract\AbstractRequest::setBody() * @covers PmConverter\Converters\Abstract\AbstractRequest::setBody()
* @covers PmConverter\Converters\Abstract\AbstractRequest::setJsonBody() * @covers PmConverter\Converters\Abstract\AbstractRequest::setBodyAsIs()
* @covers PmConverter\Converters\Abstract\AbstractRequest::getBody() * @covers PmConverter\Converters\Abstract\AbstractRequest::getBody()
* @return void * @return void
*/ */