update after stop build
All checks were successful
All checks were successful
This commit is contained in:
63
vendor/knuckleswtf/scribe/src/Writing/CustomTranslationsLoader.php
vendored
Normal file
63
vendor/knuckleswtf/scribe/src/Writing/CustomTranslationsLoader.php
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Knuckles\Scribe\Writing;
|
||||
|
||||
use Illuminate\Contracts\Translation\Loader as LoaderContract;
|
||||
use Illuminate\Translation\FileLoader;
|
||||
|
||||
class CustomTranslationsLoader extends FileLoader
|
||||
{
|
||||
protected LoaderContract $defaultLoader;
|
||||
|
||||
protected mixed $langPath;
|
||||
|
||||
protected ?array $scribeTranslationsCache = null;
|
||||
|
||||
protected ?array $userTranslationsCache = null;
|
||||
|
||||
public function __construct(LoaderContract $loader)
|
||||
{
|
||||
$this->defaultLoader = $loader;
|
||||
$this->files = app('files');
|
||||
$this->langPath = app('path.lang');
|
||||
}
|
||||
|
||||
public function load($locale, $group, $namespace = null)
|
||||
{
|
||||
// Laravel expects translation strings to be broken up into groups (files):
|
||||
// `lang/scribe/en/auth.php`, `lang/scribe/en/links.php`
|
||||
// We want to trick it into accepting a simple `lang/scribe.php`.
|
||||
|
||||
if ($namespace === 'scribe') {
|
||||
if (isset($this->scribeTranslationsCache)) {
|
||||
$lines = $this->scribeTranslationsCache[$group] ?? [];
|
||||
} elseif ($this->files->exists($full = "{$this->hints[$namespace]}/scribe.php")) {
|
||||
$this->scribeTranslationsCache = $this->files->getRequire($full);
|
||||
// getRequire() requires the Scribe file, which will return an array
|
||||
$lines = $this->scribeTranslationsCache[$group] ?? [];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->loadScribeNamespaceOverrides($lines, $locale, $group, $namespace);
|
||||
}
|
||||
|
||||
return $this->defaultLoader->load($locale, $group, $namespace);
|
||||
}
|
||||
|
||||
protected function loadScribeNamespaceOverrides(array $lines, $locale, $group, $namespace)
|
||||
{
|
||||
$userTranslationsFile = "{$this->langPath}/scribe.php";
|
||||
|
||||
if ($this->files->exists($userTranslationsFile)) {
|
||||
if (! isset($this->userTranslationsCache)) {
|
||||
$this->userTranslationsCache = $this->files->getRequire($userTranslationsFile);
|
||||
}
|
||||
$userTranslations = $this->userTranslationsCache[$group] ?? [];
|
||||
|
||||
return array_replace_recursive($lines, $userTranslations);
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
}
|
||||
49
vendor/knuckleswtf/scribe/src/Writing/ExternalHtmlWriter.php
vendored
Normal file
49
vendor/knuckleswtf/scribe/src/Writing/ExternalHtmlWriter.php
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Knuckles\Scribe\Writing;
|
||||
|
||||
use Illuminate\Support\Facades\View;
|
||||
|
||||
/**
|
||||
* Writes a basic, mostly empty template, passing the OpenAPI spec URL in for an external client-side renderer.
|
||||
*/
|
||||
class ExternalHtmlWriter extends HtmlWriter
|
||||
{
|
||||
public function generate(array $groupedEndpoints, string $sourceFolder, string $destinationFolder)
|
||||
{
|
||||
$template = $this->config->get('theme');
|
||||
$output = View::make("scribe::external.{$template}", [
|
||||
'metadata' => $this->getMetadata(),
|
||||
'baseUrl' => $this->baseUrl,
|
||||
'tryItOut' => $this->config->get('try_it_out'),
|
||||
'htmlAttributes' => $this->config->get('external.html_attributes', []),
|
||||
])->render();
|
||||
|
||||
if (! is_dir($destinationFolder)) {
|
||||
mkdir($destinationFolder, 0o777, true);
|
||||
}
|
||||
|
||||
file_put_contents($destinationFolder.'/index.html', $output);
|
||||
}
|
||||
|
||||
public function getMetadata(): array
|
||||
{
|
||||
// NB:These paths are wrong for laravel type but will be set correctly by the Writer class
|
||||
if ($this->config->get('postman.enabled', true)) {
|
||||
$postmanCollectionUrl = "{$this->assetPathPrefix}collection.json";
|
||||
}
|
||||
if ($this->config->get('openapi.enabled', false)) {
|
||||
$openApiSpecUrl = "{$this->assetPathPrefix}openapi.yaml";
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $this->config->get('title') ?: config('app.name', '').' Documentation',
|
||||
'example_languages' => $this->config->get('example_languages'), // may be useful
|
||||
'logo' => $this->config->get('logo') ?? false,
|
||||
'last_updated' => $this->getLastUpdated(), // may be useful
|
||||
'try_it_out' => $this->config->get('try_it_out'), // may be useful
|
||||
'postman_collection_url' => $postmanCollectionUrl ?? null,
|
||||
'openapi_spec_url' => $openApiSpecUrl ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
234
vendor/knuckleswtf/scribe/src/Writing/HtmlWriter.php
vendored
Normal file
234
vendor/knuckleswtf/scribe/src/Writing/HtmlWriter.php
vendored
Normal file
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
namespace Knuckles\Scribe\Writing;
|
||||
|
||||
use http\Exception\InvalidArgumentException;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\Str;
|
||||
use Knuckles\Camel\Output\OutputEndpointData;
|
||||
use Knuckles\Scribe\Tools\DocumentationConfig;
|
||||
use Knuckles\Scribe\Tools\MarkdownParser;
|
||||
use Knuckles\Scribe\Tools\Utils;
|
||||
use Knuckles\Scribe\Tools\WritingUtils;
|
||||
|
||||
/**
|
||||
* Transforms the extracted data (endpoints YAML, API details Markdown) into a HTML site.
|
||||
*/
|
||||
class HtmlWriter
|
||||
{
|
||||
protected DocumentationConfig $config;
|
||||
|
||||
protected string $baseUrl;
|
||||
|
||||
protected string $assetPathPrefix;
|
||||
|
||||
protected MarkdownParser $markdownParser;
|
||||
|
||||
public function __construct(?DocumentationConfig $config = null)
|
||||
{
|
||||
$this->config = $config ?: new DocumentationConfig(config('scribe', []));
|
||||
$this->markdownParser = new MarkdownParser;
|
||||
$this->baseUrl = $this->config->get('base_url') ?? config('app.url');
|
||||
// If they're using the default static path,
|
||||
// then use '../docs/{asset}', so assets can work via Laravel app or via index.html
|
||||
$this->assetPathPrefix = '../docs/';
|
||||
if (in_array($this->config->get('type'), ['static', 'external_static'])
|
||||
&& mb_rtrim($this->config->get('static.output_path', ''), '/') !== 'public/docs'
|
||||
) {
|
||||
$this->assetPathPrefix = './';
|
||||
}
|
||||
}
|
||||
|
||||
public function generate(array $groupedEndpoints, string $sourceFolder, string $destinationFolder)
|
||||
{
|
||||
$intro = $this->transformMarkdownFileToHTML($sourceFolder.'/intro.md');
|
||||
$auth = $this->transformMarkdownFileToHTML($sourceFolder.'/auth.md');
|
||||
$headingsBeforeEndpoints = $this->markdownParser->headings;
|
||||
|
||||
$this->markdownParser->headings = [];
|
||||
$appendFile = mb_rtrim($sourceFolder, '/').'/append.md';
|
||||
$append = file_exists($appendFile) ? $this->transformMarkdownFileToHTML($appendFile) : '';
|
||||
$headingsAfterEndpoints = $this->markdownParser->headings;
|
||||
|
||||
foreach ($groupedEndpoints as &$group) {
|
||||
$group['subgroups'] = collect($group['endpoints'])->groupBy('metadata.subgroup')->all();
|
||||
}
|
||||
$theme = $this->config->get('theme') ?? 'default';
|
||||
$output = View::make("scribe::themes.{$theme}.index", [
|
||||
'metadata' => $this->getMetadata(),
|
||||
'baseUrl' => $this->baseUrl,
|
||||
'tryItOut' => $this->config->get('try_it_out'),
|
||||
'intro' => $intro,
|
||||
'auth' => $auth,
|
||||
'groupedEndpoints' => $groupedEndpoints,
|
||||
'headings' => $this->getHeadings($headingsBeforeEndpoints, $groupedEndpoints, $headingsAfterEndpoints),
|
||||
'append' => $append,
|
||||
'assetPathPrefix' => $this->assetPathPrefix,
|
||||
])->render();
|
||||
|
||||
if (! is_dir($destinationFolder)) {
|
||||
mkdir($destinationFolder, 0o777, true);
|
||||
}
|
||||
|
||||
file_put_contents($destinationFolder.'/index.html', $output);
|
||||
|
||||
// Copy assets
|
||||
$assetsFolder = __DIR__.'/../../resources';
|
||||
// Prune older versioned assets
|
||||
if (is_dir($destinationFolder.'/css')) {
|
||||
Utils::deleteDirectoryAndContents($destinationFolder.'/css');
|
||||
}
|
||||
if (is_dir($destinationFolder.'/js')) {
|
||||
Utils::deleteDirectoryAndContents($destinationFolder.'/js');
|
||||
}
|
||||
Utils::copyDirectory("{$assetsFolder}/images/", "{$destinationFolder}/images");
|
||||
|
||||
$assets = [
|
||||
"{$assetsFolder}/css/theme-{$theme}.style.css" => ["{$destinationFolder}/css/", "theme-{$theme}.style.css"],
|
||||
"{$assetsFolder}/css/theme-{$theme}.print.css" => ["{$destinationFolder}/css/", "theme-{$theme}.print.css"],
|
||||
"{$assetsFolder}/js/theme-{$theme}.js" => ["{$destinationFolder}/js/", WritingUtils::getVersionedAsset("theme-{$theme}.js")],
|
||||
];
|
||||
|
||||
if ($this->config->get('try_it_out.enabled', true)) {
|
||||
$assets["{$assetsFolder}/js/tryitout.js"] = ["{$destinationFolder}/js/", WritingUtils::getVersionedAsset('tryitout.js')];
|
||||
}
|
||||
|
||||
foreach ($assets as $path => [$destination, $fileName]) {
|
||||
if (file_exists($path)) {
|
||||
if (! is_dir($destination)) {
|
||||
mkdir($destination, 0o777, true);
|
||||
}
|
||||
copy($path, $destination.$fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getMetadata(): array
|
||||
{
|
||||
// NB:These paths are wrong for laravel type but will be set correctly by the Writer class
|
||||
if ($this->config->get('postman.enabled', true)) {
|
||||
$postmanCollectionUrl = "{$this->assetPathPrefix}collection.json";
|
||||
}
|
||||
if ($this->config->get('openapi.enabled', false)) {
|
||||
$openApiSpecUrl = "{$this->assetPathPrefix}openapi.yaml";
|
||||
}
|
||||
|
||||
$auth = $this->config->get('auth');
|
||||
if ($auth) {
|
||||
if ($auth['in'] === 'bearer' || $auth['in'] === 'basic') {
|
||||
$auth['name'] = 'Authorization';
|
||||
$auth['location'] = 'header';
|
||||
$auth['prefix'] = ucfirst($auth['in']).' ';
|
||||
} else {
|
||||
$auth['location'] = $auth['in'];
|
||||
$auth['prefix'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $this->config->get('title') ?: config('app.name', '').' Documentation',
|
||||
'example_languages' => $this->config->get('example_languages'),
|
||||
'logo' => $this->config->get('logo') ?? false,
|
||||
'last_updated' => $this->getLastUpdated(),
|
||||
'auth' => $auth,
|
||||
'try_it_out' => $this->config->get('try_it_out'),
|
||||
'postman_collection_url' => $postmanCollectionUrl ?? null,
|
||||
'openapi_spec_url' => $openApiSpecUrl ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
protected function transformMarkdownFileToHTML(string $markdownFilePath): string
|
||||
{
|
||||
return $this->markdownParser->text(file_get_contents($markdownFilePath));
|
||||
}
|
||||
|
||||
protected function getLastUpdated()
|
||||
{
|
||||
$lastUpdated = $this->config->get('last_updated', 'Last updated: {date:F j, Y}');
|
||||
|
||||
$tokens = [
|
||||
'date' => fn ($format) => date($format),
|
||||
'git' => fn ($format) => match ($format) {
|
||||
'short' => mb_trim(shell_exec('git rev-parse --short HEAD')),
|
||||
'long' => mb_trim(shell_exec('git rev-parse HEAD')),
|
||||
default => throw new InvalidArgumentException("The `git` token only supports formats 'short' and 'long', but you specified {$format}"),
|
||||
},
|
||||
];
|
||||
|
||||
foreach ($tokens as $token => $resolver) {
|
||||
$matches = [];
|
||||
if (preg_match('#(\{'.$token.':(.+?)})#', $lastUpdated, $matches)) {
|
||||
$lastUpdated = str_replace($matches[1], $resolver($matches[2]), $lastUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
return $lastUpdated;
|
||||
}
|
||||
|
||||
protected function getHeadings(array $headingsBeforeEndpoints, array $endpointsByGroupAndSubgroup, array $headingsAfterEndpoints)
|
||||
{
|
||||
$headings = [];
|
||||
|
||||
$lastL1ElementIndex = null;
|
||||
foreach ($headingsBeforeEndpoints as $heading) {
|
||||
$element = [
|
||||
'slug' => $heading['slug'],
|
||||
'name' => $heading['text'],
|
||||
'subheadings' => [],
|
||||
];
|
||||
if ($heading['level'] === 1) {
|
||||
$headings[] = $element;
|
||||
$lastL1ElementIndex = count($headings) - 1;
|
||||
} elseif ($heading['level'] === 2 && ! is_null($lastL1ElementIndex)) {
|
||||
$headings[$lastL1ElementIndex]['subheadings'][] = $element;
|
||||
}
|
||||
}
|
||||
|
||||
$headings = array_merge($headings, array_values(array_map(function ($group) {
|
||||
$groupSlug = Str::slug($group['name']);
|
||||
|
||||
return [
|
||||
'slug' => $groupSlug,
|
||||
'name' => $group['name'],
|
||||
'subheadings' => collect($group['subgroups'])->flatMap(function ($endpoints, $subgroupName) use ($groupSlug) {
|
||||
if ($subgroupName === '') {
|
||||
return $endpoints->map(fn (OutputEndpointData $endpoint) => [
|
||||
'slug' => $endpoint->fullSlug(),
|
||||
'name' => $endpoint->name(),
|
||||
'subheadings' => [],
|
||||
])->values();
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
'slug' => "{$groupSlug}-".Str::slug($subgroupName),
|
||||
'name' => $subgroupName,
|
||||
'subheadings' => $endpoints->map(fn ($endpoint) => [
|
||||
'slug' => $endpoint->fullSlug(),
|
||||
'name' => $endpoint->name(),
|
||||
'subheadings' => [],
|
||||
])->values(),
|
||||
],
|
||||
];
|
||||
})->values(),
|
||||
];
|
||||
}, $endpointsByGroupAndSubgroup)));
|
||||
|
||||
$lastL1ElementIndex = null;
|
||||
foreach ($headingsAfterEndpoints as $heading) {
|
||||
$element = [
|
||||
'slug' => $heading['slug'],
|
||||
'name' => $heading['text'],
|
||||
'subheadings' => [],
|
||||
];
|
||||
if ($heading['level'] === 1) {
|
||||
$headings[] = $element;
|
||||
$lastL1ElementIndex = count($headings) - 1;
|
||||
} elseif ($heading['level'] === 2 && ! is_null($lastL1ElementIndex)) {
|
||||
$headings[$lastL1ElementIndex]['subheadings'][] = $element;
|
||||
}
|
||||
}
|
||||
|
||||
return $headings;
|
||||
}
|
||||
}
|
||||
118
vendor/knuckleswtf/scribe/src/Writing/OpenAPISpecWriter.php
vendored
Normal file
118
vendor/knuckleswtf/scribe/src/Writing/OpenAPISpecWriter.php
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Knuckles\Scribe\Writing;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Knuckles\Camel\Output\OutputEndpointData;
|
||||
use Knuckles\Scribe\Extracting\ParamHelpers;
|
||||
use Knuckles\Scribe\Tools\DocumentationConfig;
|
||||
use Knuckles\Scribe\Writing\OpenApiSpecGenerators\Base31Generator;
|
||||
use Knuckles\Scribe\Writing\OpenApiSpecGenerators\BaseGenerator;
|
||||
use Knuckles\Scribe\Writing\OpenApiSpecGenerators\OpenApiGenerator;
|
||||
use Knuckles\Scribe\Writing\OpenApiSpecGenerators\OverridesGenerator;
|
||||
use Knuckles\Scribe\Writing\OpenApiSpecGenerators\SecurityGenerator;
|
||||
|
||||
class OpenAPISpecWriter
|
||||
{
|
||||
use ParamHelpers;
|
||||
|
||||
public const SPEC_VERSION = '3.0.3';
|
||||
|
||||
private DocumentationConfig $config;
|
||||
|
||||
/**
|
||||
* @var Collection<int, OpenApiGenerator>
|
||||
*/
|
||||
private Collection $generators;
|
||||
|
||||
public function __construct(?DocumentationConfig $config = null)
|
||||
{
|
||||
$this->config = $config ?: new DocumentationConfig(config('scribe', []));
|
||||
$generators = [
|
||||
$this->isOpenApi31OrLater() ? Base31Generator::class : BaseGenerator::class,
|
||||
SecurityGenerator::class,
|
||||
OverridesGenerator::class,
|
||||
];
|
||||
$this->generators = collect($generators)
|
||||
->merge($this->config->get('openapi.generators', []))
|
||||
->map(fn ($generatorClass) => app()->makeWith($generatorClass, ['config' => $this->config]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OpenAPI spec version to use from config, defaulting to 3.0.3.
|
||||
* Supported versions: '3.0.3', '3.1.0'.
|
||||
*
|
||||
* @return string The OpenAPI version
|
||||
*/
|
||||
public function getSpecVersion(): string
|
||||
{
|
||||
return $this->config->get('openapi.version', self::SPEC_VERSION);
|
||||
}
|
||||
|
||||
/**
|
||||
* See https://swagger.io/specification/.
|
||||
*
|
||||
* @param array<int, array{description: string, name: string, endpoints: OutputEndpointData[]}> $groupedEndpoints
|
||||
*/
|
||||
public function generateSpecContent(array $groupedEndpoints): array
|
||||
{
|
||||
$paths = ['paths' => $this->generatePathsSpec($groupedEndpoints)];
|
||||
|
||||
$content = [];
|
||||
foreach ($this->generators as $generator) {
|
||||
$content = $generator->root($content, $groupedEndpoints);
|
||||
}
|
||||
|
||||
return array_replace_recursive($content, $paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{description: string, name: string, endpoints: OutputEndpointData[]}> $groupedEndpoints
|
||||
*/
|
||||
protected function generatePathsSpec(array $groupedEndpoints): array
|
||||
{
|
||||
$allEndpoints = collect($groupedEndpoints)->map->endpoints->flatten(1);
|
||||
// OpenAPI groups endpoints by path, then method
|
||||
$groupedByPath = $allEndpoints->groupBy(function ($endpoint) {
|
||||
$path = str_replace('?}', '}', $endpoint->uri); // Remove optional parameters indicator in path
|
||||
|
||||
return '/'.mb_ltrim($path, '/');
|
||||
});
|
||||
|
||||
return $groupedByPath->mapWithKeys(function (Collection $endpoints, $path) use ($groupedEndpoints) {
|
||||
$operations = $endpoints->mapWithKeys(function (OutputEndpointData $endpoint) use ($groupedEndpoints) {
|
||||
$spec = [];
|
||||
|
||||
foreach ($this->generators as $generator) {
|
||||
$spec = $generator->pathItem($spec, $groupedEndpoints, $endpoint);
|
||||
}
|
||||
|
||||
return [mb_strtolower($endpoint->httpMethods[0]) => $spec];
|
||||
});
|
||||
|
||||
$pathItem = $operations;
|
||||
|
||||
// Placing all URL parameters at the path level, since it's the same path anyway
|
||||
/** @var OutputEndpointData $urlParameterEndpoint */
|
||||
$urlParameterEndpoint = $endpoints[0];
|
||||
|
||||
$parameters = [];
|
||||
|
||||
foreach ($this->generators as $generator) {
|
||||
$parameters = $generator->pathParameters($parameters, $endpoints->all(), $urlParameterEndpoint->urlParameters);
|
||||
}
|
||||
if (! empty($parameters)) {
|
||||
$pathItem['parameters'] = array_values($parameters);
|
||||
}
|
||||
|
||||
return [$path => $pathItem];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
protected function isOpenApi31OrLater(): bool
|
||||
{
|
||||
$version = $this->config->get('openapi.version', self::SPEC_VERSION);
|
||||
|
||||
return version_compare($version, '3.1.0', '>=');
|
||||
}
|
||||
}
|
||||
155
vendor/knuckleswtf/scribe/src/Writing/OpenApiSpecGenerators/Base31Generator.php
vendored
Normal file
155
vendor/knuckleswtf/scribe/src/Writing/OpenApiSpecGenerators/Base31Generator.php
vendored
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace Knuckles\Scribe\Writing\OpenApiSpecGenerators;
|
||||
|
||||
use Knuckles\Camel\Output\Parameter;
|
||||
|
||||
/**
|
||||
* The main generator for Open API Spec, for v3.1.
|
||||
*/
|
||||
class Base31Generator extends BaseGenerator
|
||||
{
|
||||
/**
|
||||
* Handle nullable fields based on OpenAPI version.
|
||||
* In OpenAPI 3.0, use 'nullable: true'.
|
||||
* In OpenAPI 3.1, use JSON Schema's type array syntax: 'type: ["string", "null"]'.
|
||||
*/
|
||||
protected function applyNullable(array &$schema, bool $nullable): void
|
||||
{
|
||||
if (! $nullable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// OpenAPI 3.1 uses JSON Schema's type array syntax
|
||||
if (isset($schema['type'])) {
|
||||
$currentType = $schema['type'];
|
||||
// Don't modify if already an array
|
||||
if (! is_array($currentType)) {
|
||||
$schema['type'] = [$currentType, 'null'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override parent's generateFieldData to convert 'example' to 'examples' for OpenAPI 3.1.
|
||||
* In OpenAPI 3.1, JSON Schema's 'examples' (plural, as an array) is preferred over 'example'.
|
||||
*/
|
||||
public function generateFieldData($field): array
|
||||
{
|
||||
$fieldData = parent::generateFieldData($field);
|
||||
$this->convertExampleInSchemaToExamples($fieldData);
|
||||
|
||||
return $fieldData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override parent's generateSchemaForResponseValue to convert 'example' to 'examples' for OpenAPI 3.1.
|
||||
*/
|
||||
public function generateSchemaForResponseValue(mixed $value, \Knuckles\Camel\Output\OutputEndpointData $endpoint, string $path): array
|
||||
{
|
||||
$schema = parent::generateSchemaForResponseValue($value, $endpoint, $path);
|
||||
$this->convertExampleInSchemaToExamples($schema);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override parent's generateResponseContentSpec to convert 'example' to 'examples' for OpenAPI 3.1.
|
||||
*/
|
||||
protected function generateResponseContentSpec(?string $responseContent, \Knuckles\Camel\Output\OutputEndpointData $endpoint): array
|
||||
{
|
||||
$contentSpec = parent::generateResponseContentSpec($responseContent, $endpoint);
|
||||
|
||||
// Convert example to examples in all schemas within the content spec
|
||||
foreach ($contentSpec as $contentType => &$content) {
|
||||
if (isset($content['schema'])) {
|
||||
$this->convertExampleInSchemaToExamples($content['schema']);
|
||||
}
|
||||
}
|
||||
|
||||
return $contentSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert 'example' to 'examples' for OpenAPI 3.1 compatibility.
|
||||
* OpenAPI 3.1 uses JSON Schema, which prefers 'examples' (plural, as an array).
|
||||
*/
|
||||
protected function convertExampleInSchemaToExamples(array &$schema): void
|
||||
{
|
||||
// Only convert if 'example' exists and 'examples' doesn't already exist
|
||||
// If both exist, prioritize 'examples' and remove 'example' to avoid conflicts
|
||||
if (array_key_exists('example', $schema)) {
|
||||
if (! array_key_exists('examples', $schema)) {
|
||||
$schema['examples'] = [$schema['example']];
|
||||
}
|
||||
// Remove 'example' to ensure only 'examples' is present in OpenAPI 3.1
|
||||
unset($schema['example']);
|
||||
}
|
||||
|
||||
// Recursively handle nested properties
|
||||
if (isset($schema['properties']) && is_array($schema['properties'])) {
|
||||
foreach ($schema['properties'] as &$property) {
|
||||
$this->convertExampleInSchemaToExamples($property);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle items in arrays
|
||||
if (isset($schema['items']) && is_array($schema['items'])) {
|
||||
$this->convertExampleInSchemaToExamples($schema['items']);
|
||||
}
|
||||
}
|
||||
|
||||
protected function convertExampleOutsideSchemaToExamples(array &$data): void
|
||||
{
|
||||
// Only convert if 'example' exists and 'examples' doesn't already exist
|
||||
// If both exist, prioritize 'examples' and remove 'example' to avoid conflicts
|
||||
if (isset($data['example'])) {
|
||||
if (! isset($data['schema']['examples'])) {
|
||||
$data['schema']['examples'] = [$data['example']];
|
||||
}
|
||||
// Remove 'example' to ensure only 'examples' is present in OpenAPI 3.1
|
||||
unset($data['example']);
|
||||
}
|
||||
|
||||
// Recursively handle nested properties
|
||||
if (isset($data['schema']['properties']) && is_array($data['schema']['properties'])) {
|
||||
foreach ($data['schema']['properties'] as &$property) {
|
||||
$this->convertExampleInSchemaToExamples($property);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle items in arrays
|
||||
if (isset($data['schema']['items']) && is_array($data['schema']['items'])) {
|
||||
$this->convertExampleInSchemaToExamples($data['schema']['items']);
|
||||
}
|
||||
}
|
||||
|
||||
protected function headerToOpenApiParameterObject(string $name, string $value): array
|
||||
{
|
||||
$data = parent::headerToOpenApiParameterObject($name, $value);
|
||||
$this->convertExampleOutsideSchemaToExamples($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function queryParamToOpenApiParameterObject(string $name, Parameter $details): array
|
||||
{
|
||||
$data = parent::queryParamToOpenApiParameterObject($name, $details);
|
||||
$this->convertExampleOutsideSchemaToExamples($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function urlParamToOpenApiParameterObject(string $name, Parameter $details): array
|
||||
{
|
||||
$data = parent::urlParamToOpenApiParameterObject($name, $details);
|
||||
if (isset($data['examples'])) {
|
||||
// This is NOT the JSON Schema 'examples' array, but the OpenAPI Parameter Object 'examples' Map, outside the schema. Leave untouched
|
||||
return $data;
|
||||
}
|
||||
|
||||
$this->convertExampleOutsideSchemaToExamples($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
706
vendor/knuckleswtf/scribe/src/Writing/OpenApiSpecGenerators/BaseGenerator.php
vendored
Normal file
706
vendor/knuckleswtf/scribe/src/Writing/OpenApiSpecGenerators/BaseGenerator.php
vendored
Normal file
@@ -0,0 +1,706 @@
|
||||
<?php
|
||||
|
||||
namespace Knuckles\Scribe\Writing\OpenApiSpecGenerators;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Knuckles\Camel\Camel;
|
||||
use Knuckles\Camel\Extraction\Response;
|
||||
use Knuckles\Camel\Extraction\ResponseField;
|
||||
use Knuckles\Camel\Output\OutputEndpointData;
|
||||
use Knuckles\Camel\Output\Parameter;
|
||||
use Knuckles\Scribe\Tools\Utils;
|
||||
use Knuckles\Scribe\Writing\OpenAPISpecWriter;
|
||||
|
||||
/**
|
||||
* The main generator for Open API Spec. It adds the minimum needed information to the spec.
|
||||
*/
|
||||
class BaseGenerator extends OpenApiGenerator
|
||||
{
|
||||
public function root(array $root, array $groupedEndpoints): array
|
||||
{
|
||||
return array_merge($root, [
|
||||
'openapi' => $this->config->get('openapi.version', OpenAPISpecWriter::SPEC_VERSION),
|
||||
'info' => [
|
||||
'title' => $this->config->get('title') ?: config('app.name', ''),
|
||||
'description' => $this->config->get('description', ''),
|
||||
'version' => '1.0.0',
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'url' => mb_rtrim($this->config->get('base_url') ?? config('app.url'), '/'),
|
||||
],
|
||||
],
|
||||
'tags' => array_values(array_map(function (array $group) {
|
||||
return [
|
||||
'name' => $group['name'],
|
||||
'description' => $group['description'],
|
||||
];
|
||||
}, $groupedEndpoints)),
|
||||
]);
|
||||
}
|
||||
|
||||
public function pathItem(array $pathItem, array $groupedEndpoints, OutputEndpointData $endpoint): array
|
||||
{
|
||||
$spec = [
|
||||
'summary' => $endpoint->metadata->title,
|
||||
'operationId' => $this->operationId($endpoint),
|
||||
'description' => $endpoint->metadata->description,
|
||||
'parameters' => $this->generateEndpointParametersSpec($endpoint),
|
||||
'responses' => $this->generateEndpointResponsesSpec($endpoint),
|
||||
'tags' => [Arr::first($groupedEndpoints, function ($group) use ($endpoint) {
|
||||
return Camel::doesGroupContainEndpoint($group, $endpoint);
|
||||
})['name']],
|
||||
];
|
||||
|
||||
if ($endpoint->metadata->deprecated) {
|
||||
$spec['deprecated'] = true;
|
||||
}
|
||||
|
||||
if (count($endpoint->bodyParameters)) {
|
||||
$spec['requestBody'] = $this->generateEndpointRequestBodySpec($endpoint);
|
||||
}
|
||||
|
||||
return array_merge($pathItem, $spec);
|
||||
}
|
||||
|
||||
public function pathParameters(array $parameters, array $endpoints, array $urlParameters): array
|
||||
{
|
||||
foreach ($urlParameters as $name => $details) {
|
||||
$parameterData = $this->urlParamToOpenApiParameterObject($name, $details);
|
||||
$parameters[$name] = $parameterData;
|
||||
}
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|Parameter $field
|
||||
*/
|
||||
public function generateFieldData($field): array
|
||||
{
|
||||
if (is_array($field)) {
|
||||
$field = new Parameter($field);
|
||||
}
|
||||
|
||||
if ($field->type === 'file') {
|
||||
// See https://swagger.io/docs/specification/describing-request-body/file-upload/
|
||||
$fieldData = [
|
||||
'type' => 'string',
|
||||
'format' => 'binary',
|
||||
'description' => $field->description ?: '',
|
||||
];
|
||||
$this->applyNullable($fieldData, $field->nullable);
|
||||
|
||||
return $fieldData;
|
||||
}
|
||||
if (Utils::isArrayType($field->type)) {
|
||||
$baseType = Utils::getBaseTypeFromArrayType($field->type);
|
||||
$baseItem = ($baseType === 'file') ? [
|
||||
'type' => 'string',
|
||||
'format' => 'binary',
|
||||
] : ['type' => $baseType];
|
||||
|
||||
if (! empty($field->enumValues)) {
|
||||
$baseItem['enum'] = $field->enumValues;
|
||||
}
|
||||
|
||||
$this->applyNullable($baseItem, $field->nullable);
|
||||
|
||||
$fieldData = [
|
||||
'type' => 'array',
|
||||
'description' => $field->description ?: '',
|
||||
'example' => $field->example,
|
||||
'items' => Utils::isArrayType($baseType)
|
||||
? $this->generateFieldData([
|
||||
'name' => '',
|
||||
'type' => $baseType,
|
||||
'example' => ($field->example ?: [null])[0],
|
||||
'nullable' => $field->nullable,
|
||||
])
|
||||
: $baseItem,
|
||||
];
|
||||
if (str_replace('[]', '', $field->type) === 'file') {
|
||||
// Don't include example for file params in OAS; it's hard to translate it correctly
|
||||
unset($fieldData['example']);
|
||||
}
|
||||
|
||||
if ($baseType === 'object' && ! empty($field->__fields)) {
|
||||
if ($fieldData['items']['type'] === 'object') {
|
||||
$fieldData['items']['properties'] = [];
|
||||
}
|
||||
foreach ($field->__fields as $fieldSimpleName => $subfield) {
|
||||
$fieldData['items']['properties'][$fieldSimpleName] = $this->generateFieldData($subfield);
|
||||
if ($subfield['required']) {
|
||||
$fieldData['items']['required'][] = $fieldSimpleName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $fieldData;
|
||||
}
|
||||
if ($field->type === 'object') {
|
||||
$data = [
|
||||
'type' => 'object',
|
||||
'description' => $field->description ?: '',
|
||||
'example' => $field->example,
|
||||
'properties' => $this->objectIfEmpty(collect($field->__fields)->mapWithKeys(function ($subfield, $subfieldName) {
|
||||
return [$subfieldName => $this->generateFieldData($subfield)];
|
||||
})->all()),
|
||||
'required' => collect($field->__fields)->filter(fn ($f) => $f['required'])->keys()->toArray(),
|
||||
];
|
||||
$this->applyNullable($data, $field->nullable);
|
||||
// The spec doesn't allow for an empty `required` array. Must have something there.
|
||||
if (empty($data['required'])) {
|
||||
unset($data['required']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
$schema = [
|
||||
'type' => static::normalizeTypeName($field->type),
|
||||
'description' => $field->description ?: '',
|
||||
'example' => $field->example,
|
||||
];
|
||||
if (! empty($field->enumValues)) {
|
||||
$schema['enum'] = $field->enumValues;
|
||||
}
|
||||
$this->applyNullable($schema, $field->nullable);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a value, generate the schema for it. The schema consists of: {type:, example:, properties: (if value is an
|
||||
* object)}, and possibly a description for each property. The $endpoint and $path are used for looking up response
|
||||
* field descriptions.
|
||||
*/
|
||||
public function generateSchemaForResponseValue(mixed $value, OutputEndpointData $endpoint, string $path): array
|
||||
{
|
||||
if ($value instanceof \stdClass) {
|
||||
$value = (array) $value;
|
||||
$properties = [];
|
||||
// Recurse into the object
|
||||
foreach ($value as $subField => $subValue) {
|
||||
$subFieldPath = sprintf('%s.%s', $path, $subField);
|
||||
$properties[$subField] = $this->generateSchemaForResponseValue($subValue, $endpoint, $subFieldPath);
|
||||
}
|
||||
$required = $this->filterRequiredResponseFields($endpoint, array_keys($properties), $path);
|
||||
|
||||
$schema = [
|
||||
'type' => 'object',
|
||||
'properties' => $this->objectIfEmpty($properties),
|
||||
];
|
||||
if ($required) {
|
||||
$schema['required'] = $required;
|
||||
}
|
||||
$this->setDescription($schema, $endpoint, $path);
|
||||
$this->setNullable($schema, $endpoint, $path, $value);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($value)),
|
||||
'example' => $value,
|
||||
];
|
||||
$this->setDescription($schema, $endpoint, $path);
|
||||
$this->setNullable($schema, $endpoint, $path, $value);
|
||||
|
||||
// Set enum values for the property if they exist
|
||||
if (! empty($endpoint->responseFields[$path]->enumValues)) {
|
||||
$schema['enum'] = $endpoint->responseFields[$path]->enumValues;
|
||||
}
|
||||
|
||||
if ($schema['type'] === 'array' && ! empty($value)) {
|
||||
$schema['example'] = json_decode(json_encode($schema['example']), true); // Convert stdClass to array
|
||||
|
||||
$sample = $value[0];
|
||||
$typeOfEachItem = $this->convertScribeOrPHPTypeToOpenAPIType(gettype($sample));
|
||||
$schema['items']['type'] = $typeOfEachItem;
|
||||
|
||||
if ($typeOfEachItem === 'object') {
|
||||
$schema['items']['properties'] = collect($sample)->mapWithKeys(function ($v, $k) use ($endpoint, $path) {
|
||||
return [$k => $this->generateSchemaForResponseValue($v, $endpoint, "{$path}.{$k}")];
|
||||
})->toArray();
|
||||
|
||||
$required = $this->filterRequiredResponseFields(
|
||||
$endpoint,
|
||||
array_keys($schema['items']['properties']),
|
||||
$path
|
||||
);
|
||||
if ($required) {
|
||||
$schema['required'] = $required;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an enpoint and a set of object keys at a path, return the properties that are specified as required.
|
||||
*/
|
||||
public function filterRequiredResponseFields(OutputEndpointData $endpoint, array $properties, string $path = ''): array
|
||||
{
|
||||
$required = [];
|
||||
foreach ($properties as $property) {
|
||||
$responseField = $endpoint->responseFields["{$path}.{$property}"] ?? $endpoint->responseFields[$property] ?? null;
|
||||
if ($responseField && $responseField->required) {
|
||||
$required[] = $property;
|
||||
}
|
||||
}
|
||||
|
||||
return $required;
|
||||
}
|
||||
|
||||
protected function operationId(OutputEndpointData $endpoint): string
|
||||
{
|
||||
if ($endpoint->metadata->title) {
|
||||
return preg_replace('/[^\w+]/', '', Str::camel($endpoint->metadata->title));
|
||||
}
|
||||
|
||||
$parts = preg_split('/[^\w+]/', $endpoint->uri, -1, PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
return Str::lower($endpoint->httpMethods[0]).implode('', array_map(fn ($part) => ucfirst($part), $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add query parameters and headers.
|
||||
*
|
||||
* @return array<int, array<string,mixed>>
|
||||
*/
|
||||
protected function generateEndpointParametersSpec(OutputEndpointData $endpoint): array
|
||||
{
|
||||
$parameters = [];
|
||||
|
||||
$parameters = $this->generateQueryParams($endpoint, $parameters);
|
||||
|
||||
$parameters = $this->generateHeaders($endpoint, $parameters);
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
protected function generateEndpointRequestBodySpec(OutputEndpointData $endpoint): array|\stdClass
|
||||
{
|
||||
$body = [];
|
||||
|
||||
if (count($endpoint->bodyParameters)) {
|
||||
$schema = [
|
||||
'type' => 'object',
|
||||
'properties' => [],
|
||||
];
|
||||
|
||||
$hasRequiredParameter = false;
|
||||
$hasFileParameter = false;
|
||||
|
||||
foreach ($endpoint->nestedBodyParameters as $name => $details) {
|
||||
if ($name === '[]') { // Request body is an array
|
||||
$hasRequiredParameter = true;
|
||||
$schema = $this->generateFieldData($details);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if ($details['required']) {
|
||||
$hasRequiredParameter = true;
|
||||
// Don't declare this earlier.
|
||||
// The spec doesn't allow for an empty `required` array. Must have something there.
|
||||
$schema['required'][] = $name;
|
||||
}
|
||||
|
||||
if ($details['type'] === 'file') {
|
||||
$hasFileParameter = true;
|
||||
}
|
||||
|
||||
$fieldData = $this->generateFieldData($details);
|
||||
if ($details['deprecated']) {
|
||||
$fieldData['deprecated'] = true;
|
||||
}
|
||||
|
||||
$schema['properties'][$name] = $fieldData;
|
||||
}
|
||||
|
||||
// We remove 'properties' if the request body is an array, so we need to check if it's still there
|
||||
if (array_key_exists('properties', $schema)) {
|
||||
$schema['properties'] = $this->objectIfEmpty($schema['properties']);
|
||||
}
|
||||
$body['required'] = $hasRequiredParameter;
|
||||
|
||||
if ($hasFileParameter) {
|
||||
// If there are file parameters, content type changes to multipart
|
||||
$contentType = 'multipart/form-data';
|
||||
} elseif (isset($endpoint->headers['Content-Type'])) {
|
||||
$contentType = $endpoint->headers['Content-Type'];
|
||||
} else {
|
||||
$contentType = 'application/json';
|
||||
}
|
||||
|
||||
$body['content'][$contentType]['schema'] = $schema;
|
||||
}
|
||||
|
||||
// return object rather than empty array, so can get properly serialised as object
|
||||
return $this->objectIfEmpty($body);
|
||||
}
|
||||
|
||||
protected function generateEndpointResponsesSpec(OutputEndpointData $endpoint)
|
||||
{
|
||||
// See https://swagger.io/docs/specification/describing-responses/
|
||||
$responses = [];
|
||||
|
||||
foreach ($endpoint->responses as $response) {
|
||||
$code = $response->status; // OpenAPI spec requires status codes to be integers
|
||||
// OpenAPI groups responses by status code
|
||||
// Only one response type per status code, so only the last one will be used
|
||||
if ($code === '204') {
|
||||
// Must not add content for 204
|
||||
$responses[$code] = [
|
||||
'description' => $this->getResponseDescription($response),
|
||||
];
|
||||
} elseif (isset($responses[$code])) {
|
||||
// If we already have a response for this status code and content type,
|
||||
// we change to a `oneOf` which includes all the responses
|
||||
$content = $this->generateResponseContentSpec($response->content, $endpoint);
|
||||
$contentType = array_keys($content)[0];
|
||||
if (isset($responses[$code]['content'][$contentType])) {
|
||||
$newResponseExample = array_replace([
|
||||
'description' => $this->getResponseDescription($response),
|
||||
], $content[$contentType]['schema']);
|
||||
|
||||
// If we've already created the oneOf object, add this response
|
||||
if (isset($responses[$code]['content'][$contentType]['schema']['oneOf'])) {
|
||||
$responses[$code]['content'][$contentType]['schema']['oneOf'][] = $newResponseExample;
|
||||
} else {
|
||||
// Create the oneOf object
|
||||
$existingResponseExample = array_replace([
|
||||
'description' => $responses[$code]['description'],
|
||||
], $responses[$code]['content'][$contentType]['schema']);
|
||||
|
||||
$responses[$code]['description'] = '';
|
||||
$responses[$code]['content'][$contentType]['schema'] = [
|
||||
'oneOf' => [$existingResponseExample, $newResponseExample],
|
||||
];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Store as the response for this status
|
||||
$responses[$code] = [
|
||||
'description' => $this->getResponseDescription($response),
|
||||
'content' => $this->generateResponseContentSpec($response->content, $endpoint),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// return object rather than empty array, so can get properly serialised as object
|
||||
return $this->objectIfEmpty($responses);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
protected function generateResponseContentSpec(?string $responseContent, OutputEndpointData $endpoint)
|
||||
{
|
||||
if (Str::startsWith($responseContent, '<<binary>>')) {
|
||||
return [
|
||||
'application/octet-stream' => [
|
||||
'schema' => [
|
||||
'type' => 'string',
|
||||
'format' => 'binary',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($responseContent === null) {
|
||||
$schema = [
|
||||
'type' => 'object',
|
||||
];
|
||||
$this->applyNullable($schema, true);
|
||||
|
||||
return [
|
||||
'application/json' => [
|
||||
'schema' => $schema,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$decoded = json_decode($responseContent);
|
||||
if ($decoded === null) { // Decoding failed, so we return the content string as is
|
||||
return [
|
||||
'text/plain' => [
|
||||
'schema' => [
|
||||
'type' => 'string',
|
||||
'example' => $responseContent,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$response = $endpoint->responses->where('content', $responseContent)->first();
|
||||
$contentType = $response->headers['content-type'] ?? $response->headers['Content-Type'] ?? 'application/json';
|
||||
|
||||
switch ($type = gettype($decoded)) {
|
||||
case 'string':
|
||||
case 'boolean':
|
||||
case 'integer':
|
||||
case 'double':
|
||||
return [
|
||||
$contentType => [
|
||||
'schema' => [
|
||||
'type' => $type === 'double' ? 'number' : $type,
|
||||
'example' => $decoded,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
case 'array':
|
||||
if (! count($decoded)) {
|
||||
// empty array
|
||||
return [
|
||||
$contentType => [
|
||||
'schema' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object', // No better idea what to put here
|
||||
],
|
||||
'example' => $decoded,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Non-empty array
|
||||
if (is_object($decoded[0])) {
|
||||
// If the first item is an object, we assume it's an array of objects'
|
||||
$properties = collect($decoded[0])->mapWithKeys(function ($value, $key) use ($endpoint) {
|
||||
return [$key => $this->generateSchemaForResponseValue($value, $endpoint, $key)];
|
||||
})->toArray();
|
||||
|
||||
return [
|
||||
$contentType => [
|
||||
'schema' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($decoded[0])),
|
||||
'properties' => $this->objectIfEmpty($properties),
|
||||
],
|
||||
'example' => $decoded,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
$contentType => [
|
||||
'schema' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($decoded[0])),
|
||||
],
|
||||
'example' => $decoded,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
case 'object':
|
||||
$properties = collect($decoded)->mapWithKeys(function ($value, $key) use ($endpoint) {
|
||||
return [$key => $this->generateSchemaForResponseValue($value, $endpoint, $key)];
|
||||
})->toArray();
|
||||
$required = $this->filterRequiredResponseFields($endpoint, array_keys($properties));
|
||||
|
||||
$data = [
|
||||
$contentType => [
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'example' => $decoded,
|
||||
'properties' => $this->objectIfEmpty($properties),
|
||||
],
|
||||
],
|
||||
];
|
||||
if ($required) {
|
||||
$data[$contentType]['schema']['required'] = $required;
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an array, return an object if the array is empty. To be used with fields that are
|
||||
* required by OpenAPI spec to be objects, since empty arrays get serialised as [].
|
||||
*/
|
||||
protected function objectIfEmpty(array $field): array|\stdClass
|
||||
{
|
||||
return count($field) > 0 ? $field : new \stdClass;
|
||||
}
|
||||
|
||||
protected function convertScribeOrPHPTypeToOpenAPIType($type)
|
||||
{
|
||||
return match ($type) {
|
||||
'float', 'double' => 'number',
|
||||
'NULL' => 'string',
|
||||
default => $type,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle nullable fields based on OpenAPI version.
|
||||
* In OpenAPI 3.0, use 'nullable: true'.
|
||||
* In OpenAPI 3.1, use JSON Schema's type array syntax: 'type: ["string", "null"]'.
|
||||
*/
|
||||
protected function applyNullable(array &$schema, bool $nullable): void
|
||||
{
|
||||
if (! $nullable) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema['nullable'] = true;
|
||||
}
|
||||
|
||||
// Set the description for the schema. If the field has a description, it is set in the schema.
|
||||
private function setDescription(array &$schema, OutputEndpointData $endpoint, string $path): void
|
||||
{
|
||||
if (! empty($endpoint->responseFields[$path]->description)) {
|
||||
$schema['description'] = $endpoint->responseFields[$path]->description;
|
||||
}
|
||||
}
|
||||
|
||||
// Set the nullable for the schema. If the field is nullable, it is set in the schema.
|
||||
private function setNullable(array &$schema, OutputEndpointData $endpoint, string $path, mixed $value): void
|
||||
{
|
||||
/** @var null|ResponseField $field */
|
||||
$field = $endpoint->responseFields[$path] ?? null;
|
||||
|
||||
// prefer explicit values
|
||||
if ($field !== null && $field->nullable !== null) {
|
||||
if ($field->nullable) {
|
||||
$this->applyNullable($schema, true);
|
||||
}
|
||||
|
||||
// false => do not set and do not use example
|
||||
return;
|
||||
}
|
||||
|
||||
// example is null
|
||||
if ($value === null) {
|
||||
$this->applyNullable($schema, true);
|
||||
}
|
||||
}
|
||||
|
||||
protected function generateQueryParams(OutputEndpointData $endpoint, array $parameters): array
|
||||
{
|
||||
if (count($endpoint->queryParameters)) {
|
||||
/**
|
||||
* @var string $name
|
||||
* @var Parameter $details
|
||||
*/
|
||||
foreach ($endpoint->queryParameters as $name => $details) {
|
||||
$parameterData = $this->queryParamToOpenApiParameterObject($name, $details);
|
||||
$parameters[] = $parameterData;
|
||||
}
|
||||
}
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
protected function generateHeaders(OutputEndpointData $endpoint, mixed $parameters): mixed
|
||||
{
|
||||
if (count($endpoint->headers)) {
|
||||
foreach ($endpoint->headers as $name => $value) {
|
||||
if (in_array(mb_strtolower($name), ['content-type', 'accept', 'authorization'])) {
|
||||
// These headers are not allowed in the spec.
|
||||
// https://swagger.io/docs/specification/describing-parameters/#header-parameters
|
||||
continue;
|
||||
}
|
||||
|
||||
$parameters[] = $this->headerToOpenApiParameterObject($name, $value);
|
||||
}
|
||||
}
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
protected function headerToOpenApiParameterObject(string $name, string $value): array
|
||||
{
|
||||
return [
|
||||
'in' => 'header',
|
||||
'name' => $name,
|
||||
'description' => '',
|
||||
'example' => $value,
|
||||
'schema' => [
|
||||
'type' => 'string',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function queryParamToOpenApiParameterObject(string $name, Parameter $details): array
|
||||
{
|
||||
$parameterData = [
|
||||
'in' => 'query',
|
||||
'name' => $name,
|
||||
'description' => $details->description,
|
||||
'example' => $details->example,
|
||||
'required' => $details->required,
|
||||
'schema' => $this->generateFieldData($details),
|
||||
];
|
||||
if ($details->deprecated) {
|
||||
$parameterData['deprecated'] = true;
|
||||
}
|
||||
|
||||
return $parameterData;
|
||||
}
|
||||
|
||||
protected function urlParamToOpenApiParameterObject(string $name, Parameter $details): array
|
||||
{
|
||||
$parameterData = [
|
||||
'in' => 'path',
|
||||
'name' => $name,
|
||||
'description' => $details->description,
|
||||
'example' => $details->example,
|
||||
// Currently, OAS requires path parameters to be required
|
||||
'required' => true,
|
||||
'schema' => [
|
||||
'type' => $details->type,
|
||||
],
|
||||
];
|
||||
// Workaround for optional parameters
|
||||
if (empty($details->required)) {
|
||||
$parameterData['description'] = rtrim('Optional parameter. '.$parameterData['description']);
|
||||
$parameterData['examples'] = [
|
||||
'omitted' => [
|
||||
'summary' => 'When the value is omitted',
|
||||
'value' => '',
|
||||
],
|
||||
];
|
||||
|
||||
if ($parameterData['example'] !== null) {
|
||||
$parameterData['examples']['present'] = [
|
||||
'summary' => 'When the value is present',
|
||||
'value' => $parameterData['example'],
|
||||
];
|
||||
}
|
||||
|
||||
// Can't have `example` and `examples`
|
||||
unset($parameterData['example']);
|
||||
}
|
||||
|
||||
return $parameterData;
|
||||
}
|
||||
}
|
||||
63
vendor/knuckleswtf/scribe/src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php
vendored
Normal file
63
vendor/knuckleswtf/scribe/src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Knuckles\Scribe\Writing\OpenApiSpecGenerators;
|
||||
|
||||
use Knuckles\Camel\Output\OutputEndpointData;
|
||||
use Knuckles\Camel\Output\Parameter;
|
||||
use Knuckles\Scribe\Extracting\ParamHelpers;
|
||||
use Knuckles\Scribe\Tools\DocumentationConfig;
|
||||
|
||||
/**
|
||||
* Class used to generate OpenAPI spec.
|
||||
*
|
||||
* This class is responsible for generating the OpenAPI spec for your API. For additional customization, you can extend
|
||||
* this class and override the methods.
|
||||
* Each method corresponds to a different part of the OpenAPI spec. The return value of each method will set the value,
|
||||
* so if you want to extend on what other generators have done, add to the array and return it.
|
||||
*/
|
||||
abstract class OpenApiGenerator
|
||||
{
|
||||
use ParamHelpers;
|
||||
|
||||
public function __construct(protected DocumentationConfig $config) {}
|
||||
|
||||
/**
|
||||
* This section is the root of the OpenAPI document. It contains general info about the API.
|
||||
*
|
||||
* @param array<int, array{description: string, name: string, endpoints: OutputEndpointData[]}> $groupedEndpoints
|
||||
*
|
||||
* @see https://spec.openapis.org/oas/v3.1.1.html#openapi-object
|
||||
*/
|
||||
public function root(array $root, array $groupedEndpoints): array
|
||||
{
|
||||
return $root;
|
||||
}
|
||||
|
||||
/**
|
||||
* This section is the individual path item object in an OpenApi document. It contains the details of the specific
|
||||
* endpoint. This will be called for each individual endpoint, e.g. post, get, put, delete, etc.
|
||||
*
|
||||
* @param array<int, array{description: string, name: string, endpoints: OutputEndpointData[]}> $groupedEndpoints
|
||||
*
|
||||
* @see https://spec.openapis.org/oas/v3.1.1.html#path-item-object
|
||||
*/
|
||||
public function pathItem(array $pathItem, array $groupedEndpoints, OutputEndpointData $endpoint): array
|
||||
{
|
||||
return $pathItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* This section of the spec is the parameters object inside the path item object. It contains the details of all the
|
||||
* parameters for the endpoints matching the path. This will be called for each individual path, e.g. /users, /posts
|
||||
* it will not be called if a path has multiple endpoints, e.g. get and post.
|
||||
*
|
||||
* @param OutputEndpointData[] $endpoints
|
||||
* @param Parameter[] $urlParameters
|
||||
*
|
||||
* @see https://spec.openapis.org/oas/v3.1.1.html#parameter-object
|
||||
*/
|
||||
public function pathParameters(array $parameters, array $endpoints, array $urlParameters): array
|
||||
{
|
||||
return $parameters;
|
||||
}
|
||||
}
|
||||
15
vendor/knuckleswtf/scribe/src/Writing/OpenApiSpecGenerators/OverridesGenerator.php
vendored
Normal file
15
vendor/knuckleswtf/scribe/src/Writing/OpenApiSpecGenerators/OverridesGenerator.php
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Knuckles\Scribe\Writing\OpenApiSpecGenerators;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class OverridesGenerator extends OpenApiGenerator
|
||||
{
|
||||
public function root(array $root, array $groupedEndpoints): array
|
||||
{
|
||||
$overrides = $this->config->get('openapi.overrides', []);
|
||||
|
||||
return array_replace_recursive($root, Arr::undot($overrides));
|
||||
}
|
||||
}
|
||||
60
vendor/knuckleswtf/scribe/src/Writing/OpenApiSpecGenerators/SecurityGenerator.php
vendored
Normal file
60
vendor/knuckleswtf/scribe/src/Writing/OpenApiSpecGenerators/SecurityGenerator.php
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Knuckles\Scribe\Writing\OpenApiSpecGenerators;
|
||||
|
||||
use Knuckles\Camel\Output\OutputEndpointData;
|
||||
|
||||
class SecurityGenerator extends OpenApiGenerator
|
||||
{
|
||||
public function root(array $root, array $groupedEndpoints): array
|
||||
{
|
||||
$isApiAuthed = $this->config->get('auth.enabled', false);
|
||||
if (! $isApiAuthed) {
|
||||
return $root;
|
||||
}
|
||||
|
||||
$location = $this->config->get('auth.in');
|
||||
$parameterName = $this->config->get('auth.name');
|
||||
$description = $this->config->get('auth.extra_info');
|
||||
$scheme = match ($location) {
|
||||
'query', 'header' => [
|
||||
'type' => 'apiKey',
|
||||
'name' => $parameterName,
|
||||
'in' => $location,
|
||||
'description' => $description,
|
||||
],
|
||||
'bearer', 'basic' => [
|
||||
'type' => 'http',
|
||||
'scheme' => $location,
|
||||
'description' => $description,
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
|
||||
return array_merge_recursive($root, [
|
||||
// All security schemes must be registered in `components.securitySchemes`...
|
||||
'components' => [
|
||||
'securitySchemes' => [
|
||||
// 'default' is an arbitrary name for the auth scheme. Can be anything, really.
|
||||
'default' => $scheme,
|
||||
],
|
||||
],
|
||||
// ...and then can be applied in `security`
|
||||
'security' => [
|
||||
[
|
||||
'default' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function pathItem(array $pathItem, array $groupedEndpoints, OutputEndpointData $endpoint): array
|
||||
{
|
||||
if (! $endpoint->metadata->authenticated) {
|
||||
// Make sure to exclude non-auth endpoints from auth
|
||||
$pathItem['security'] = [];
|
||||
}
|
||||
|
||||
return $pathItem;
|
||||
}
|
||||
}
|
||||
404
vendor/knuckleswtf/scribe/src/Writing/PostmanCollectionWriter.php
vendored
Normal file
404
vendor/knuckleswtf/scribe/src/Writing/PostmanCollectionWriter.php
vendored
Normal file
@@ -0,0 +1,404 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
288
vendor/knuckleswtf/scribe/src/Writing/Writer.php
vendored
Normal file
288
vendor/knuckleswtf/scribe/src/Writing/Writer.php
vendored
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace Knuckles\Scribe\Writing;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
|
||||
use Knuckles\Scribe\Tools\DocumentationConfig;
|
||||
use Knuckles\Scribe\Tools\Globals;
|
||||
use Knuckles\Scribe\Tools\PathConfig;
|
||||
use Knuckles\Scribe\Tools\Utils;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class Writer
|
||||
{
|
||||
protected bool $isStatic;
|
||||
|
||||
protected bool $isExternal;
|
||||
|
||||
protected ?string $staticTypeOutputPath;
|
||||
|
||||
protected ?string $laravelTypeOutputPath;
|
||||
|
||||
protected array $generatedFiles = [
|
||||
'postman' => null,
|
||||
'openapi' => null,
|
||||
'html' => null,
|
||||
'blade' => null,
|
||||
'assets' => [
|
||||
'js' => null,
|
||||
'css' => null,
|
||||
'images' => null,
|
||||
],
|
||||
];
|
||||
|
||||
protected string $laravelAssetsPath;
|
||||
|
||||
public function __construct(protected DocumentationConfig $config, public PathConfig $paths)
|
||||
{
|
||||
$this->isStatic = $this->config->outputIsStatic();
|
||||
$this->isExternal = $this->config->outputIsExternal();
|
||||
|
||||
$this->laravelTypeOutputPath = $this->getLaravelTypeOutputPath();
|
||||
$this->staticTypeOutputPath = mb_rtrim($this->config->get('static.output_path', 'public/docs'), '/');
|
||||
|
||||
$this->laravelAssetsPath = $this->config->get('laravel.assets_directory')
|
||||
? '/'.$this->config->get('laravel.assets_directory')
|
||||
: '/vendor/'.$this->paths->outputPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array> $groupedEndpoints
|
||||
*/
|
||||
public function writeDocs(array $groupedEndpoints): void
|
||||
{
|
||||
// The static assets (js/, css/, and images/) always go in public/docs/.
|
||||
// For 'static' docs, the output files (index.html, collection.json) go in public/docs/.
|
||||
// For 'laravel' docs, the output files (index.blade.php, collection.json)
|
||||
// go in resources/views/scribe/ and storage/app/scribe/ respectively.
|
||||
|
||||
if ($this->isExternal) {
|
||||
$this->writeOpenAPISpec($groupedEndpoints);
|
||||
$this->writePostmanCollection($groupedEndpoints);
|
||||
$this->writeExternalHtmlDocs();
|
||||
} else {
|
||||
$this->writeHtmlDocs($groupedEndpoints);
|
||||
$this->writePostmanCollection($groupedEndpoints);
|
||||
$this->writeOpenAPISpec($groupedEndpoints);
|
||||
}
|
||||
|
||||
$this->runAfterGeneratingHook();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Postman collection JSON file.
|
||||
*
|
||||
* @param array[] $groupedEndpoints
|
||||
*/
|
||||
public function generatePostmanCollection(array $groupedEndpoints): string
|
||||
{
|
||||
/** @var PostmanCollectionWriter $writer */
|
||||
$writer = app()->makeWith(PostmanCollectionWriter::class, ['config' => $this->config]);
|
||||
|
||||
$collection = $writer->generatePostmanCollection($groupedEndpoints);
|
||||
$overrides = $this->config->get('postman.overrides', []);
|
||||
if (count($overrides)) {
|
||||
foreach ($overrides as $key => $value) {
|
||||
data_set($collection, $key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
return json_encode($collection, JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array[] $groupedEndpoints
|
||||
*/
|
||||
public function generateOpenAPISpec(array $groupedEndpoints): string
|
||||
{
|
||||
/** @var OpenAPISpecWriter $writer */
|
||||
$writer = app()->makeWith(OpenAPISpecWriter::class, ['config' => $this->config]);
|
||||
$spec = $writer->generateSpecContent($groupedEndpoints);
|
||||
|
||||
return Yaml::dump($spec, 20, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array[] $groupedEndpoints
|
||||
*/
|
||||
public function writeHtmlDocs(array $groupedEndpoints): void
|
||||
{
|
||||
if ($this->isStatic) {
|
||||
$outputPath = mb_rtrim($this->staticTypeOutputPath, '/').'/';
|
||||
$assetsOutputPath = $outputPath;
|
||||
} else {
|
||||
$outputPath = mb_rtrim($this->laravelTypeOutputPath, '/').'/';
|
||||
$assetsOutputPath = public_path().$this->laravelAssetsPath.'/';
|
||||
}
|
||||
c::task(
|
||||
'Writing '.($this->isStatic ? 'HTML' : 'Blade').' docs to '.$this->makePathFriendly($outputPath).' and assets to '.$this->makePathFriendly($assetsOutputPath),
|
||||
function () use ($assetsOutputPath, $outputPath, $groupedEndpoints) {
|
||||
// Then we convert them to HTML, and throw in the endpoints as well.
|
||||
/** @var HtmlWriter $writer */
|
||||
$writer = app()->makeWith(HtmlWriter::class, ['config' => $this->config]);
|
||||
$writer->generate($groupedEndpoints, $this->paths->intermediateOutputPath(), $this->staticTypeOutputPath);
|
||||
|
||||
if (! $this->isStatic) {
|
||||
$this->performFinalTasksForLaravelType();
|
||||
}
|
||||
|
||||
if ($this->isStatic) {
|
||||
$this->generatedFiles['html'] = realpath("{$outputPath}index.html");
|
||||
} else {
|
||||
$this->generatedFiles['blade'] = realpath("{$outputPath}index.blade.php");
|
||||
}
|
||||
$this->generatedFiles['assets']['js'] = realpath("{$assetsOutputPath}js");
|
||||
$this->generatedFiles['assets']['css'] = realpath("{$assetsOutputPath}css");
|
||||
$this->generatedFiles['assets']['images'] = realpath("{$assetsOutputPath}images");
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function writeExternalHtmlDocs(): void
|
||||
{
|
||||
if ($this->isStatic) {
|
||||
$outputPath = mb_rtrim($this->staticTypeOutputPath, '/').'/';
|
||||
$assetsOutputPath = $outputPath;
|
||||
} else {
|
||||
$outputPath = mb_rtrim($this->laravelTypeOutputPath, '/').'/';
|
||||
$assetsOutputPath = public_path().$this->laravelAssetsPath.'/';
|
||||
}
|
||||
c::task(
|
||||
'Writing client-side HTML docs to '.$this->makePathFriendly($outputPath).' and assets to '.$this->makePathFriendly($assetsOutputPath),
|
||||
function () use ($outputPath) {
|
||||
/** @var ExternalHtmlWriter $writer */
|
||||
$writer = app()->makeWith(ExternalHtmlWriter::class, ['config' => $this->config]);
|
||||
$writer->generate([], $this->paths->intermediateOutputPath(), $this->staticTypeOutputPath);
|
||||
|
||||
if (! $this->isStatic) {
|
||||
$this->performFinalTasksForLaravelType();
|
||||
}
|
||||
|
||||
if ($this->isStatic) {
|
||||
$this->generatedFiles['html'] = realpath("{$outputPath}index.html");
|
||||
} else {
|
||||
$this->generatedFiles['blade'] = realpath("{$outputPath}index.blade.php");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function writePostmanCollection(array $groups): void
|
||||
{
|
||||
if ($this->config->get('postman.enabled', true)) {
|
||||
$outputPath = $this->isStatic ? $this->staticTypeOutputPath : Storage::disk('local')->path($this->paths->outputPath());
|
||||
c::task(
|
||||
'Generating Postman collection in '.mb_rtrim($this->makePathFriendly($outputPath), '/').'/',
|
||||
function () use ($groups) {
|
||||
$collection = $this->generatePostmanCollection($groups);
|
||||
if ($this->isStatic) {
|
||||
$collectionPath = "{$this->staticTypeOutputPath}/collection.json";
|
||||
file_put_contents($collectionPath, $collection);
|
||||
} else {
|
||||
$outputPath = $this->paths->outputPath('collection.json');
|
||||
Storage::disk('local')->put($outputPath, $collection);
|
||||
$collectionPath = Storage::disk('local')->path($outputPath);
|
||||
}
|
||||
|
||||
$this->generatedFiles['postman'] = realpath($collectionPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function writeOpenAPISpec(array $parsedRoutes): void
|
||||
{
|
||||
if ($this->config->get('openapi.enabled', false) || $this->isExternal) {
|
||||
$outputPath = $this->isStatic ? $this->staticTypeOutputPath : Storage::disk('local')->path($this->paths->outputPath());
|
||||
c::task(
|
||||
'Generating OpenAPI specification in '.mb_rtrim($this->makePathFriendly($outputPath), '/').'/',
|
||||
function () use ($parsedRoutes) {
|
||||
$spec = $this->generateOpenAPISpec($parsedRoutes);
|
||||
if ($this->isStatic) {
|
||||
Utils::makeDirectoryRecursive($this->staticTypeOutputPath);
|
||||
$specPath = "{$this->staticTypeOutputPath}/openapi.yaml";
|
||||
file_put_contents($specPath, $spec);
|
||||
} else {
|
||||
$outputPath = $this->paths->outputPath('openapi.yaml');
|
||||
Storage::disk('local')->put($outputPath, $spec);
|
||||
$specPath = Storage::disk('local')->path($outputPath);
|
||||
}
|
||||
|
||||
$this->generatedFiles['openapi'] = realpath($specPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function performFinalTasksForLaravelType(): void
|
||||
{
|
||||
if (! is_dir($this->laravelTypeOutputPath)) {
|
||||
mkdir($this->laravelTypeOutputPath, 0o777, true);
|
||||
}
|
||||
$publicDirectory = public_path();
|
||||
if (! is_dir($publicDirectory.$this->laravelAssetsPath)) {
|
||||
mkdir($publicDirectory.$this->laravelAssetsPath, 0o777, true);
|
||||
}
|
||||
|
||||
// Transform output HTML to a Blade view
|
||||
rename("{$this->staticTypeOutputPath}/index.html", "{$this->laravelTypeOutputPath}/index.blade.php");
|
||||
|
||||
// Move assets from public/docs to public/vendor/scribe or config('laravel.assets_directory')
|
||||
// We need to do this delete first, otherwise move won't work if folder exists
|
||||
Utils::deleteDirectoryAndContents($publicDirectory.$this->laravelAssetsPath);
|
||||
rename("{$this->staticTypeOutputPath}/", $publicDirectory.$this->laravelAssetsPath);
|
||||
|
||||
$contents = file_get_contents("{$this->laravelTypeOutputPath}/index.blade.php");
|
||||
|
||||
// Rewrite asset links to go through Laravel
|
||||
$contents = preg_replace('#href="\.\./docs/css/(.+?)"#', 'href="{{ asset("'.$this->laravelAssetsPath.'/css/$1") }}"', $contents);
|
||||
$contents = preg_replace('#src="\.\./docs/(js|images)/(.+?)"#', 'src="{{ asset("'.$this->laravelAssetsPath.'/$1/$2") }}"', $contents);
|
||||
$contents = str_replace('href="../docs/collection.json"', 'href="{{ route("'.$this->paths->outputPath('postman', '.').'") }}"', $contents);
|
||||
$contents = str_replace('href="../docs/openapi.yaml"', 'href="{{ route("'.$this->paths->outputPath('openapi', '.').'") }}"', $contents);
|
||||
$contents = str_replace('url="../docs/openapi.yaml"', 'url="{{ route("'.$this->paths->outputPath('openapi', '.').'") }}"', $contents);
|
||||
// With Elements theme, we'd have <elements-api apiDescriptionUrl="../docs/openapi.yaml"
|
||||
$contents = str_replace('Url="../docs/openapi.yaml"', 'Url="{{ route("'.$this->paths->outputPath('openapi', '.').'") }}"', $contents);
|
||||
|
||||
file_put_contents("{$this->laravelTypeOutputPath}/index.blade.php", $contents);
|
||||
}
|
||||
|
||||
protected function runAfterGeneratingHook()
|
||||
{
|
||||
if (is_callable(Globals::$__afterGenerating)) {
|
||||
c::info('Running `afterGenerating()` hook...');
|
||||
call_user_func_array(Globals::$__afterGenerating, [$this->generatedFiles]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getLaravelTypeOutputPath(): ?string
|
||||
{
|
||||
if ($this->isStatic) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return config(
|
||||
'view.paths.0',
|
||||
function_exists('base_path') ? base_path('resources/views') : 'resources/views'
|
||||
).'/'.$this->paths->outputPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a path from (possibly) C:\projects\myapp\resources\views
|
||||
* or /projects/myapp/resources/views to resources/views ie:
|
||||
* - make it relative to PWD
|
||||
* - normalise all slashes to forward slashes.
|
||||
*/
|
||||
protected function makePathFriendly(string $path): string
|
||||
{
|
||||
return str_replace('\\', '/', str_replace(getcwd().DIRECTORY_SEPARATOR, '', $path));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user