update after stop build
All checks were successful
Build, Push and Deploy / build-and-push (push) Successful in 3m36s
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 16s
Build, Push and Deploy / deploy-staging (push) Successful in 42s
Build, Push and Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-04-18 16:10:57 +07:00
parent e449146306
commit 1228c52538
260 changed files with 43609 additions and 226 deletions

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

View 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,
];
}
}

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

View 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', '>=');
}
}

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

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

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

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

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

View 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();
}
}

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