405 lines
14 KiB
PHP
405 lines
14 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace Knuckles\Scribe\Writing;
|
||
|
|
|
||
|
|
use Illuminate\Support\Str;
|
||
|
|
use Knuckles\Camel\Extraction\Response;
|
||
|
|
use Knuckles\Camel\Output\OutputEndpointData;
|
||
|
|
use Knuckles\Camel\Output\Parameter;
|
||
|
|
use Knuckles\Scribe\Tools\DocumentationConfig;
|
||
|
|
use Ramsey\Uuid\Uuid;
|
||
|
|
|
||
|
|
class PostmanCollectionWriter
|
||
|
|
{
|
||
|
|
/**
|
||
|
|
* Postman collection schema version
|
||
|
|
* https://schema.getpostman.com/json/collection/v2.1.0/collection.json.
|
||
|
|
*/
|
||
|
|
public const SPEC_VERSION = '2.1.0';
|
||
|
|
|
||
|
|
protected DocumentationConfig $config;
|
||
|
|
|
||
|
|
protected string $baseUrl;
|
||
|
|
|
||
|
|
public function __construct(?DocumentationConfig $config = null)
|
||
|
|
{
|
||
|
|
$this->config = $config ?: new DocumentationConfig(config('scribe', []));
|
||
|
|
$this->baseUrl = $this->config->get('base_url') ?: config('app.url');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param array[] $groupedEndpoints
|
||
|
|
*/
|
||
|
|
public function generatePostmanCollection(array $groupedEndpoints): array
|
||
|
|
{
|
||
|
|
$collection = [
|
||
|
|
'variable' => [
|
||
|
|
[
|
||
|
|
'id' => 'baseUrl',
|
||
|
|
'key' => 'baseUrl',
|
||
|
|
'type' => 'string',
|
||
|
|
'name' => 'string',
|
||
|
|
'value' => $this->baseUrl,
|
||
|
|
],
|
||
|
|
],
|
||
|
|
'info' => [
|
||
|
|
'name' => $this->config->get('title') ?: config('app.name'),
|
||
|
|
'_postman_id' => Uuid::uuid4()->toString(),
|
||
|
|
'description' => $this->config->get('description', ''),
|
||
|
|
'schema' => 'https://schema.getpostman.com/json/collection/v'.self::SPEC_VERSION.'/collection.json',
|
||
|
|
],
|
||
|
|
'item' => array_values(array_map(function (array $group) {
|
||
|
|
return [
|
||
|
|
'name' => $group['name'],
|
||
|
|
'description' => $group['description'],
|
||
|
|
'item' => $this->generateSubItem($group),
|
||
|
|
];
|
||
|
|
}, $groupedEndpoints)),
|
||
|
|
'auth' => $this->generateAuthObject(),
|
||
|
|
];
|
||
|
|
|
||
|
|
return $collection;
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function generateAuthObject(): array
|
||
|
|
{
|
||
|
|
if (! $this->config->get('auth.enabled')) {
|
||
|
|
return [
|
||
|
|
'type' => 'noauth',
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
return match ($this->config->get('auth.in')) {
|
||
|
|
'basic' => [
|
||
|
|
'type' => 'basic',
|
||
|
|
],
|
||
|
|
'bearer' => [
|
||
|
|
'type' => 'bearer',
|
||
|
|
'bearer' => [
|
||
|
|
[
|
||
|
|
'key' => $this->config->get('auth.name'),
|
||
|
|
'type' => 'string',
|
||
|
|
],
|
||
|
|
],
|
||
|
|
],
|
||
|
|
default => [
|
||
|
|
'type' => 'apikey',
|
||
|
|
'apikey' => [
|
||
|
|
[
|
||
|
|
'key' => 'in',
|
||
|
|
'value' => $this->config->get('auth.in'),
|
||
|
|
'type' => 'string',
|
||
|
|
],
|
||
|
|
[
|
||
|
|
'key' => 'key',
|
||
|
|
'value' => $this->config->get('auth.name'),
|
||
|
|
'type' => 'string',
|
||
|
|
],
|
||
|
|
],
|
||
|
|
],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function generateSubItem(array $group): array
|
||
|
|
{
|
||
|
|
$seenSubgroups = [];
|
||
|
|
$items = [];
|
||
|
|
|
||
|
|
/** @var OutputEndpointData $endpoint */
|
||
|
|
foreach ($group['endpoints'] as $endpoint) {
|
||
|
|
if (! $endpoint->metadata->subgroup) {
|
||
|
|
$items[] = $this->generateEndpointItem($endpoint);
|
||
|
|
} else {
|
||
|
|
if (isset($seenSubgroups[$endpoint->metadata->subgroup])) {
|
||
|
|
$subgroupIndex = $seenSubgroups[$endpoint->metadata->subgroup];
|
||
|
|
$items[$subgroupIndex]['description'] = $items[$subgroupIndex]['description'] ?: $endpoint->metadata->subgroupDescription;
|
||
|
|
$items[$subgroupIndex]['item'] = [...$items[$subgroupIndex]['item'], $this->generateEndpointItem($endpoint)];
|
||
|
|
} else {
|
||
|
|
$items[] = [
|
||
|
|
'name' => $endpoint->metadata->subgroup,
|
||
|
|
'description' => $endpoint->metadata->subgroupDescription,
|
||
|
|
'item' => [$this->generateEndpointItem($endpoint)],
|
||
|
|
];
|
||
|
|
$seenSubgroups[$endpoint->metadata->subgroup] = count($items) - 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return $items;
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function generateEndpointItem(OutputEndpointData $endpoint): array
|
||
|
|
{
|
||
|
|
$method = $endpoint->httpMethods[0];
|
||
|
|
|
||
|
|
$bodyParameters = empty($endpoint->bodyParameters) ? null : $this->getBodyData($endpoint);
|
||
|
|
|
||
|
|
if ((in_array('PUT', $endpoint->httpMethods) || in_array('PATCH', $endpoint->httpMethods))
|
||
|
|
&& isset($bodyParameters['formdata'])) {
|
||
|
|
$method = 'POST';
|
||
|
|
$bodyParameters['formdata'][] = [
|
||
|
|
'key' => '_method',
|
||
|
|
'value' => $endpoint->httpMethods[0],
|
||
|
|
'type' => 'text',
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
$endpointItem = [
|
||
|
|
'name' => ($endpoint->metadata->title !== '' ? $endpoint->metadata->title : ($endpoint->httpMethods[0].' '.$endpoint->uri))
|
||
|
|
.($endpoint->metadata->deprecated ? ' [DEPRECATED]' : ''),
|
||
|
|
'request' => [
|
||
|
|
'url' => $this->generateUrlObject($endpoint),
|
||
|
|
'method' => $method,
|
||
|
|
'header' => $this->resolveHeadersForEndpoint($endpoint),
|
||
|
|
'body' => $bodyParameters,
|
||
|
|
'description' => $endpoint->metadata->description,
|
||
|
|
],
|
||
|
|
'response' => $this->getResponses($endpoint),
|
||
|
|
];
|
||
|
|
|
||
|
|
if ($endpoint->metadata->authenticated === false) {
|
||
|
|
$endpointItem['request']['auth'] = ['type' => 'noauth'];
|
||
|
|
}
|
||
|
|
|
||
|
|
return $endpointItem;
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function getBodyData(OutputEndpointData $endpoint): array
|
||
|
|
{
|
||
|
|
$body = [];
|
||
|
|
$contentType = $endpoint->headers['Content-Type'] ?? null;
|
||
|
|
$inputMode = match ($contentType) {
|
||
|
|
'multipart/form-data' => 'formdata',
|
||
|
|
'application/x-www-form-urlencoded' => 'urlencoded',
|
||
|
|
default => 'raw',
|
||
|
|
};
|
||
|
|
$body['mode'] = $inputMode;
|
||
|
|
$body[$inputMode] = [];
|
||
|
|
|
||
|
|
switch ($inputMode) {
|
||
|
|
case 'formdata':
|
||
|
|
case 'urlencoded':
|
||
|
|
$body[$inputMode] = $this->getFormDataParams(
|
||
|
|
$endpoint->cleanBodyParameters,
|
||
|
|
null,
|
||
|
|
$endpoint->bodyParameters
|
||
|
|
);
|
||
|
|
foreach ($endpoint->fileParameters as $key => $value) {
|
||
|
|
while (is_array($value)) {
|
||
|
|
$keys = array_keys($value);
|
||
|
|
if ($keys[0] === 0) {
|
||
|
|
// List of files
|
||
|
|
$key .= '[]';
|
||
|
|
$value = $value[0];
|
||
|
|
} else {
|
||
|
|
$key .= '['.$keys[0].']';
|
||
|
|
$value = $value[$keys[0]];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$params = [
|
||
|
|
'key' => $key,
|
||
|
|
'src' => [],
|
||
|
|
'type' => 'file',
|
||
|
|
];
|
||
|
|
$body[$inputMode][] = $params;
|
||
|
|
}
|
||
|
|
|
||
|
|
break;
|
||
|
|
|
||
|
|
case 'raw':
|
||
|
|
default:
|
||
|
|
$body[$inputMode] = json_encode($endpoint->cleanBodyParameters, JSON_UNESCAPED_UNICODE);
|
||
|
|
}
|
||
|
|
|
||
|
|
return $body;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format form-data parameters correctly for arrays eg. data[item][index] = value.
|
||
|
|
*/
|
||
|
|
protected function getFormDataParams(array $paramsKeyValue, ?string $key = null, array $paramsFullDetails = []): array
|
||
|
|
{
|
||
|
|
$body = [];
|
||
|
|
|
||
|
|
foreach ($paramsKeyValue as $index => $value) {
|
||
|
|
$index = $key ? ($key.'['.$index.']') : $index;
|
||
|
|
|
||
|
|
if (! is_array($value)) {
|
||
|
|
$body[] = [
|
||
|
|
'key' => $index,
|
||
|
|
'value' => (string) $value,
|
||
|
|
'type' => 'text',
|
||
|
|
'description' => $paramsFullDetails[$index]->description ?? '',
|
||
|
|
];
|
||
|
|
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
$body = array_merge($body, $this->getFormDataParams($value, $index));
|
||
|
|
}
|
||
|
|
|
||
|
|
return $body;
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function resolveHeadersForEndpoint(OutputEndpointData $endpointData): array
|
||
|
|
{
|
||
|
|
[$where, $authParam] = $this->getAuthParamToExclude();
|
||
|
|
|
||
|
|
$headers = collect($endpointData->headers);
|
||
|
|
if ($where === 'header') {
|
||
|
|
unset($headers[$authParam]);
|
||
|
|
}
|
||
|
|
|
||
|
|
$headers = $headers
|
||
|
|
->union([
|
||
|
|
'Accept' => 'application/json',
|
||
|
|
])
|
||
|
|
->map(function ($value, $header) {
|
||
|
|
// Allow users to write ['header' => '@{{value}}'] in config
|
||
|
|
// and have it rendered properly as {{value}} in the Postman collection.
|
||
|
|
$value = str_replace('@{{', '{{', $value);
|
||
|
|
|
||
|
|
return [
|
||
|
|
'key' => $header,
|
||
|
|
'value' => $value,
|
||
|
|
];
|
||
|
|
})
|
||
|
|
->values()
|
||
|
|
->all();
|
||
|
|
|
||
|
|
return $headers;
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function generateUrlObject(OutputEndpointData $endpointData): array
|
||
|
|
{
|
||
|
|
$base = [
|
||
|
|
'host' => '{{baseUrl}}',
|
||
|
|
// Change laravel/symfony URL params ({example}) to Postman style, prefixed with a colon
|
||
|
|
'path' => preg_replace_callback('/\{(\w+)\??}/', function ($matches) {
|
||
|
|
return ':'.$matches[1];
|
||
|
|
}, $endpointData->uri),
|
||
|
|
];
|
||
|
|
|
||
|
|
$query = [];
|
||
|
|
[$where, $authParam] = $this->getAuthParamToExclude();
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @var string $name
|
||
|
|
* @var Parameter $parameterData
|
||
|
|
*/
|
||
|
|
foreach ($endpointData->queryParameters as $name => $parameterData) {
|
||
|
|
if ($where === 'query' && $authParam === $name) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Str::endsWith($parameterData->type, '[]') || $parameterData->type === 'object') {
|
||
|
|
$values = empty($parameterData->example) ? [] : $parameterData->example;
|
||
|
|
foreach ($values as $index => $value) {
|
||
|
|
// PHP's parse_str supports array query parameters as filters[0]=name&filters[1]=age OR filters[]=name&filters[]=age
|
||
|
|
// Going with the first to also support object query parameters
|
||
|
|
// See https://www.php.net/manual/en/function.parse-str.php
|
||
|
|
$query[] = [
|
||
|
|
'key' => "{$name}[{$index}]",
|
||
|
|
'value' => is_string($value) ? $value : (string) $value,
|
||
|
|
'description' => strip_tags($parameterData->description),
|
||
|
|
// Default query params to disabled if they aren't required and have empty values
|
||
|
|
'disabled' => ! $parameterData->required && empty($parameterData->example),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
// If there are no values, add one entry so the parameter shows up in the Postman UI.
|
||
|
|
if (empty($values)) {
|
||
|
|
$query[] = [
|
||
|
|
'key' => "{$name}[]",
|
||
|
|
'value' => '',
|
||
|
|
'description' => strip_tags($parameterData->description),
|
||
|
|
// Default query params to disabled if they aren't required and have empty values
|
||
|
|
'disabled' => true,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
$query[] = [
|
||
|
|
'key' => urlencode($name),
|
||
|
|
'value' => $parameterData->example !== null ? urlencode($parameterData->example) : '',
|
||
|
|
'description' => strip_tags($parameterData->description),
|
||
|
|
// Default query params to disabled if they aren't required and have empty values
|
||
|
|
'disabled' => ! $parameterData->required && empty($parameterData->example),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$base['query'] = $query;
|
||
|
|
|
||
|
|
// Create raw url-parameter (Insomnia uses this on import)
|
||
|
|
$queryString = collect($base['query'])->map(function ($queryParamData) {
|
||
|
|
return $queryParamData['key'].'='.$queryParamData['value'];
|
||
|
|
})->implode('&');
|
||
|
|
$base['raw'] = sprintf('%s/%s%s', $base['host'], $base['path'], $queryString ? "?{$queryString}" : null);
|
||
|
|
|
||
|
|
$urlParams = collect($endpointData->urlParameters);
|
||
|
|
if ($urlParams->isEmpty()) {
|
||
|
|
return $base;
|
||
|
|
}
|
||
|
|
|
||
|
|
$base['variable'] = $urlParams->map(function (Parameter $parameter, $name) {
|
||
|
|
return [
|
||
|
|
'id' => $name,
|
||
|
|
'key' => $name,
|
||
|
|
'value' => urlencode($parameter->example),
|
||
|
|
'description' => $parameter->description,
|
||
|
|
];
|
||
|
|
})->values()->toArray();
|
||
|
|
|
||
|
|
return $base;
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function getResponseDescription(Response $response): string
|
||
|
|
{
|
||
|
|
if ($response->isBinary()) {
|
||
|
|
return mb_trim(str_replace('<<binary>>', '', $response->content));
|
||
|
|
}
|
||
|
|
|
||
|
|
$description = (string) ($response->description);
|
||
|
|
// Don't include the status code in description; see https://github.com/knuckleswtf/scribe/issues/271
|
||
|
|
if (preg_match('/\d{3},\s+(.+)/', $description, $matches)) {
|
||
|
|
$description = $matches[1];
|
||
|
|
} elseif ($description === (string) ($response->status)) {
|
||
|
|
$description = '';
|
||
|
|
}
|
||
|
|
|
||
|
|
return $description;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function getAuthParamToExclude(): array
|
||
|
|
{
|
||
|
|
if (! $this->config->get('auth.enabled')) {
|
||
|
|
return [null, null];
|
||
|
|
}
|
||
|
|
|
||
|
|
if (in_array($this->config->get('auth.in'), ['bearer', 'basic'])) {
|
||
|
|
return ['header', 'Authorization'];
|
||
|
|
}
|
||
|
|
|
||
|
|
return [$this->config->get('auth.in'), $this->config->get('auth.name')];
|
||
|
|
}
|
||
|
|
|
||
|
|
private function getResponses(OutputEndpointData $endpoint): array
|
||
|
|
{
|
||
|
|
return collect($endpoint->responses)->map(function (Response $response) {
|
||
|
|
$headers = [];
|
||
|
|
foreach ($response->headers as $header => $value) {
|
||
|
|
$headers[] = [
|
||
|
|
'key' => $header,
|
||
|
|
'value' => $value,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
return [
|
||
|
|
'header' => $headers,
|
||
|
|
'code' => $response->status,
|
||
|
|
'body' => $response->content,
|
||
|
|
'name' => $this->getResponseDescription($response),
|
||
|
|
];
|
||
|
|
})->toArray();
|
||
|
|
}
|
||
|
|
}
|