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

@@ -58,7 +58,7 @@ ENV COMPOSER_ALLOW_SUPERUSER=1 \
# Install PHP dependencies (this layer will be cached)
RUN composer config -g repo.packagist composer https://packagist.org \
&& composer install --no-interaction --no-progress --no-scripts --prefer-dist
&& composer install --no-interaction --no-progress --no-scripts --prefer-dist --ignore-platform-reqs
# Isolate the clean vendor directory
#RUN mv vendor /vendor_clean

342
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "b8877c500627363407035db84e7762b1",
"content-hash": "102ef9927311b93f5c6e22956143aee4",
"packages": [
{
"name": "ageekdev/laravel-barcode",
@@ -11964,6 +11964,99 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "knuckleswtf/scribe",
"version": "5.9.0",
"source": {
"type": "git",
"url": "https://github.com/knuckleswtf/scribe.git",
"reference": "170cb6f8c56f1b26db692fdd1f26574b1765c6f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/knuckleswtf/scribe/zipball/170cb6f8c56f1b26db692fdd1f26574b1765c6f7",
"reference": "170cb6f8c56f1b26db692fdd1f26574b1765c6f7",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"ext-json": "*",
"ext-pdo": "*",
"fakerphp/faker": "^1.23.1",
"laravel/framework": "^9.21 || ^10.0 || ^11.0 || ^12.0 || ^13.0",
"league/flysystem": "^3.0",
"mpociot/reflection-docblock": "^1.0.1",
"nikic/php-parser": "^5.0",
"nunomaduro/collision": "^6.0 || ^7.0 || ^8.0",
"parsedown/parsedown": "^1.7",
"php": ">=8.1",
"ramsey/uuid": "^4.2.2",
"shalvah/upgrader": "^0.6.0",
"symfony/var-exporter": "^6.0 || ^7.0 || ^8.0",
"symfony/yaml": "^6.0 || ^7.0 || ^8.0"
},
"replace": {
"mpociot/laravel-apidoc-generator": "*"
},
"require-dev": {
"dms/phpunit-arraysubset-asserts": "^0.5.0",
"laravel/legacy-factories": "^1.3.0",
"laravel/pint": "^1.20",
"league/fractal": "^0.20",
"nikic/fast-route": "^1.3",
"orchestra/testbench": "^7.0 || ^8.0 || ^9.10 || ^10.0 || ^11.0",
"pestphp/pest": "^1.21 || ^2.0 || ^3.0 || ^4.0",
"phpstan/phpstan": "^2.1.5",
"phpunit/phpunit": "^9.0 || ^10.0 || ^11.0 || ^12.0",
"spatie/ray": "^1.41",
"symfony/css-selector": "^6.0 || ^7.0 || ^8.0",
"symfony/dom-crawler": "^6.0 || ^7.0 || ^8.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Knuckles\\Scribe\\ScribeServiceProvider"
]
}
},
"autoload": {
"files": [
"src/Config/helpers.php"
],
"psr-4": {
"Knuckles\\Camel\\": "camel/",
"Knuckles\\Scribe\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Shalvah"
}
],
"description": "Generate API documentation for humans from your Laravel codebase.✍",
"homepage": "https://github.com/knuckleswtf/scribe",
"keywords": [
"api",
"documentation",
"laravel"
],
"support": {
"issues": "https://github.com/knuckleswtf/scribe/issues",
"source": "https://github.com/knuckleswtf/scribe/tree/5.9.0"
},
"funding": [
{
"url": "https://patreon.com/shalvah",
"type": "patreon"
}
],
"time": "2026-03-21T00:39:27+00:00"
},
{
"name": "laravel/breeze",
"version": "v1.29.1",
@@ -12239,6 +12332,59 @@
},
"time": "2024-05-16T03:13:13+00:00"
},
{
"name": "mpociot/reflection-docblock",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/mpociot/reflection-docblock.git",
"reference": "c8b2e2b1f5cebbb06e2b5ccbf2958f2198867587"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mpociot/reflection-docblock/zipball/c8b2e2b1f5cebbb06e2b5ccbf2958f2198867587",
"reference": "c8b2e2b1f5cebbb06e2b5ccbf2958f2198867587",
"shasum": ""
},
"require": {
"php": ">=5.3.3"
},
"require-dev": {
"phpunit/phpunit": "~4.0"
},
"suggest": {
"dflydev/markdown": "~1.0",
"erusev/parsedown": "~1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-0": {
"Mpociot": [
"src/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mike van Riel",
"email": "mike.vanriel@naenius.com"
}
],
"support": {
"issues": "https://github.com/mpociot/reflection-docblock/issues",
"source": "https://github.com/mpociot/reflection-docblock/tree/master"
},
"time": "2016-06-20T20:53:12+00:00"
},
{
"name": "nunomaduro/collision",
"version": "v7.12.0",
@@ -12335,6 +12481,56 @@
],
"time": "2025-03-14T22:35:49+00:00"
},
{
"name": "parsedown/parsedown",
"version": "1.8.0",
"source": {
"type": "git",
"url": "https://github.com/parsedown/parsedown.git",
"reference": "96baaad00f71ba04d76e45b4620f54d3beabd6f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/parsedown/parsedown/zipball/96baaad00f71ba04d76e45b4620f54d3beabd6f7",
"reference": "96baaad00f71ba04d76e45b4620f54d3beabd6f7",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": ">=7.1"
},
"require-dev": {
"phpunit/phpunit": "^7.5|^8.5|^9.6"
},
"type": "library",
"autoload": {
"psr-0": {
"Parsedown": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Emanuil Rusev",
"email": "hello@erusev.com",
"homepage": "http://erusev.com"
}
],
"description": "Parser for Markdown.",
"homepage": "http://parsedown.org",
"keywords": [
"markdown",
"parser"
],
"support": {
"issues": "https://github.com/parsedown/parsedown/issues",
"source": "https://github.com/parsedown/parsedown/tree/1.8.0"
},
"time": "2026-02-16T11:41:01+00:00"
},
{
"name": "phar-io/manifest",
"version": "2.0.4",
@@ -13836,6 +14032,64 @@
],
"time": "2023-02-07T11:34:05+00:00"
},
{
"name": "shalvah/upgrader",
"version": "0.6.0",
"source": {
"type": "git",
"url": "https://github.com/shalvah/upgrader.git",
"reference": "d95ed17fe9f5e1ee7d47ad835595f1af080a867f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/shalvah/upgrader/zipball/d95ed17fe9f5e1ee7d47ad835595f1af080a867f",
"reference": "d95ed17fe9f5e1ee7d47ad835595f1af080a867f",
"shasum": ""
},
"require": {
"illuminate/support": ">=8.0",
"nikic/php-parser": "^5.0",
"php": ">=8.0"
},
"require-dev": {
"dms/phpunit-arraysubset-asserts": "^0.2.0",
"pestphp/pest": "^1.21",
"phpstan/phpstan": "^1.0",
"spatie/ray": "^1.33"
},
"type": "library",
"autoload": {
"psr-4": {
"Shalvah\\Upgrader\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Shalvah",
"email": "hello@shalvah.me"
}
],
"description": "Create automatic upgrades for your package.",
"homepage": "http://github.com/shalvah/upgrader",
"keywords": [
"upgrade"
],
"support": {
"issues": "https://github.com/shalvah/upgrader/issues",
"source": "https://github.com/shalvah/upgrader/tree/0.6.0"
},
"funding": [
{
"url": "https://patreon.com/shalvah",
"type": "patreon"
}
],
"time": "2024-02-20T11:51:46+00:00"
},
{
"name": "spatie/backtrace",
"version": "1.8.1",
@@ -14217,6 +14471,86 @@
],
"time": "2025-02-20T13:13:55+00:00"
},
{
"name": "symfony/var-exporter",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-exporter.git",
"reference": "15776bb07a91b089037da89f8832fa41d5fa6ec6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/var-exporter/zipball/15776bb07a91b089037da89f8832fa41d5fa6ec6",
"reference": "15776bb07a91b089037da89f8832fa41d5fa6ec6",
"shasum": ""
},
"require": {
"php": ">=8.4"
},
"require-dev": {
"symfony/property-access": "^7.4|^8.0",
"symfony/serializer": "^7.4|^8.0",
"symfony/var-dumper": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\VarExporter\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Allows exporting any serializable PHP data structure to plain PHP code",
"homepage": "https://symfony.com",
"keywords": [
"clone",
"construct",
"export",
"hydrate",
"instantiate",
"lazy-loading",
"proxy",
"serialize"
],
"support": {
"source": "https://github.com/symfony/var-exporter/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/yaml",
"version": "v7.4.1",
@@ -14346,12 +14680,12 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.1"
},
"platform-dev": [],
"plugin-api-version": "2.3.0"
"platform-dev": {},
"plugin-api-version": "2.6.0"
}

View File

@@ -28,7 +28,7 @@ class InstalledVersions
{
/**
* @var mixed[]|null
* @psalm-var array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}|array{}|null
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
*/
private static $installed;
@@ -39,7 +39,7 @@ class InstalledVersions
/**
* @var array[]
* @psalm-var array<string, array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}>
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static $installedByVendor = array();
@@ -98,7 +98,7 @@ public static function isInstalled($packageName, $includeDevRequirements = true)
{
foreach (self::getInstalled() as $installed) {
if (isset($installed['versions'][$packageName])) {
return $includeDevRequirements || empty($installed['versions'][$packageName]['dev_requirement']);
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
}
}
@@ -119,7 +119,7 @@ public static function isInstalled($packageName, $includeDevRequirements = true)
*/
public static function satisfies(VersionParser $parser, $packageName, $constraint)
{
$constraint = $parser->parseConstraints($constraint);
$constraint = $parser->parseConstraints((string) $constraint);
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
return $provided->matches($constraint);
@@ -243,7 +243,7 @@ public static function getInstallPath($packageName)
/**
* @return array
* @psalm-return array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
*/
public static function getRootPackage()
{
@@ -257,7 +257,7 @@ public static function getRootPackage()
*
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
* @return array[]
* @psalm-return array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
*/
public static function getRawData()
{
@@ -280,7 +280,7 @@ public static function getRawData()
* Returns the raw data of all installed.php which are currently loaded for custom implementations
*
* @return array[]
* @psalm-return list<array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}>
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
public static function getAllRawData()
{
@@ -303,7 +303,7 @@ public static function getAllRawData()
* @param array[] $data A vendor/composer/installed.php data set
* @return void
*
* @psalm-param array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>} $data
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
*/
public static function reload($data)
{
@@ -313,7 +313,7 @@ public static function reload($data)
/**
* @return array[]
* @psalm-return list<array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}>
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static function getInstalled()
{
@@ -322,15 +322,20 @@ private static function getInstalled()
}
$installed = array();
$copiedLocalDir = false;
if (self::$canGetVendors) {
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
if (isset(self::$installedByVendor[$vendorDir])) {
$installed[] = self::$installedByVendor[$vendorDir];
} elseif (is_file($vendorDir.'/composer/installed.php')) {
$installed[] = self::$installedByVendor[$vendorDir] = require $vendorDir.'/composer/installed.php';
if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
self::$installed = $installed[count($installed) - 1];
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require $vendorDir.'/composer/installed.php';
self::$installedByVendor[$vendorDir] = $required;
$installed[] = $required;
if (strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
self::$installed = $required;
$copiedLocalDir = true;
}
}
}
@@ -340,12 +345,17 @@ private static function getInstalled()
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
self::$installed = require __DIR__ . '/installed.php';
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require __DIR__ . '/installed.php';
self::$installed = $required;
} else {
self::$installed = array();
}
}
$installed[] = self::$installed;
if (self::$installed !== array() && !$copiedLocalDir) {
$installed[] = self::$installed;
}
return $installed;
}

View File

@@ -3923,6 +3923,102 @@
},
"install-path": "../kavenegar/php"
},
{
"name": "knuckleswtf/scribe",
"version": "5.9.0",
"version_normalized": "5.9.0.0",
"source": {
"type": "git",
"url": "https://github.com/knuckleswtf/scribe.git",
"reference": "170cb6f8c56f1b26db692fdd1f26574b1765c6f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/knuckleswtf/scribe/zipball/170cb6f8c56f1b26db692fdd1f26574b1765c6f7",
"reference": "170cb6f8c56f1b26db692fdd1f26574b1765c6f7",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"ext-json": "*",
"ext-pdo": "*",
"fakerphp/faker": "^1.23.1",
"laravel/framework": "^9.21 || ^10.0 || ^11.0 || ^12.0 || ^13.0",
"league/flysystem": "^3.0",
"mpociot/reflection-docblock": "^1.0.1",
"nikic/php-parser": "^5.0",
"nunomaduro/collision": "^6.0 || ^7.0 || ^8.0",
"parsedown/parsedown": "^1.7",
"php": ">=8.1",
"ramsey/uuid": "^4.2.2",
"shalvah/upgrader": "^0.6.0",
"symfony/var-exporter": "^6.0 || ^7.0 || ^8.0",
"symfony/yaml": "^6.0 || ^7.0 || ^8.0"
},
"replace": {
"mpociot/laravel-apidoc-generator": "*"
},
"require-dev": {
"dms/phpunit-arraysubset-asserts": "^0.5.0",
"laravel/legacy-factories": "^1.3.0",
"laravel/pint": "^1.20",
"league/fractal": "^0.20",
"nikic/fast-route": "^1.3",
"orchestra/testbench": "^7.0 || ^8.0 || ^9.10 || ^10.0 || ^11.0",
"pestphp/pest": "^1.21 || ^2.0 || ^3.0 || ^4.0",
"phpstan/phpstan": "^2.1.5",
"phpunit/phpunit": "^9.0 || ^10.0 || ^11.0 || ^12.0",
"spatie/ray": "^1.41",
"symfony/css-selector": "^6.0 || ^7.0 || ^8.0",
"symfony/dom-crawler": "^6.0 || ^7.0 || ^8.0"
},
"time": "2026-03-21T00:39:27+00:00",
"type": "library",
"extra": {
"laravel": {
"providers": [
"Knuckles\\Scribe\\ScribeServiceProvider"
]
}
},
"installation-source": "dist",
"autoload": {
"files": [
"src/Config/helpers.php"
],
"psr-4": {
"Knuckles\\Camel\\": "camel/",
"Knuckles\\Scribe\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Shalvah"
}
],
"description": "Generate API documentation for humans from your Laravel codebase.✍",
"homepage": "https://github.com/knuckleswtf/scribe",
"keywords": [
"api",
"documentation",
"laravel"
],
"support": {
"issues": "https://github.com/knuckleswtf/scribe/issues",
"source": "https://github.com/knuckleswtf/scribe/tree/5.9.0"
},
"funding": [
{
"url": "https://patreon.com/shalvah",
"type": "patreon"
}
],
"install-path": "../knuckleswtf/scribe"
},
{
"name": "kreait/firebase-php",
"version": "7.24.0",
@@ -6530,6 +6626,62 @@
},
"install-path": "../mpdf/psr-log-aware-trait"
},
{
"name": "mpociot/reflection-docblock",
"version": "1.0.1",
"version_normalized": "1.0.1.0",
"source": {
"type": "git",
"url": "https://github.com/mpociot/reflection-docblock.git",
"reference": "c8b2e2b1f5cebbb06e2b5ccbf2958f2198867587"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mpociot/reflection-docblock/zipball/c8b2e2b1f5cebbb06e2b5ccbf2958f2198867587",
"reference": "c8b2e2b1f5cebbb06e2b5ccbf2958f2198867587",
"shasum": ""
},
"require": {
"php": ">=5.3.3"
},
"require-dev": {
"phpunit/phpunit": "~4.0"
},
"suggest": {
"dflydev/markdown": "~1.0",
"erusev/parsedown": "~1.0"
},
"time": "2016-06-20T20:53:12+00:00",
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"installation-source": "dist",
"autoload": {
"psr-0": {
"Mpociot": [
"src/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mike van Riel",
"email": "mike.vanriel@naenius.com"
}
],
"support": {
"issues": "https://github.com/mpociot/reflection-docblock/issues",
"source": "https://github.com/mpociot/reflection-docblock/tree/master"
},
"install-path": "../mpociot/reflection-docblock"
},
{
"name": "mtdowling/jmespath.php",
"version": "2.8.0",
@@ -7623,6 +7775,59 @@
},
"install-path": "../paragonie/random_compat"
},
{
"name": "parsedown/parsedown",
"version": "1.8.0",
"version_normalized": "1.8.0.0",
"source": {
"type": "git",
"url": "https://github.com/parsedown/parsedown.git",
"reference": "96baaad00f71ba04d76e45b4620f54d3beabd6f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/parsedown/parsedown/zipball/96baaad00f71ba04d76e45b4620f54d3beabd6f7",
"reference": "96baaad00f71ba04d76e45b4620f54d3beabd6f7",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": ">=7.1"
},
"require-dev": {
"phpunit/phpunit": "^7.5|^8.5|^9.6"
},
"time": "2026-02-16T11:41:01+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"psr-0": {
"Parsedown": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Emanuil Rusev",
"email": "hello@erusev.com",
"homepage": "http://erusev.com"
}
],
"description": "Parser for Markdown.",
"homepage": "http://parsedown.org",
"keywords": [
"markdown",
"parser"
],
"support": {
"issues": "https://github.com/parsedown/parsedown/issues",
"source": "https://github.com/parsedown/parsedown/tree/1.8.0"
},
"install-path": "../parsedown/parsedown"
},
{
"name": "paytm/paytmchecksum",
"version": "v1.1.0",
@@ -11141,6 +11346,67 @@
],
"install-path": "../setasign/fpdi"
},
{
"name": "shalvah/upgrader",
"version": "0.6.0",
"version_normalized": "0.6.0.0",
"source": {
"type": "git",
"url": "https://github.com/shalvah/upgrader.git",
"reference": "d95ed17fe9f5e1ee7d47ad835595f1af080a867f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/shalvah/upgrader/zipball/d95ed17fe9f5e1ee7d47ad835595f1af080a867f",
"reference": "d95ed17fe9f5e1ee7d47ad835595f1af080a867f",
"shasum": ""
},
"require": {
"illuminate/support": ">=8.0",
"nikic/php-parser": "^5.0",
"php": ">=8.0"
},
"require-dev": {
"dms/phpunit-arraysubset-asserts": "^0.2.0",
"pestphp/pest": "^1.21",
"phpstan/phpstan": "^1.0",
"spatie/ray": "^1.33"
},
"time": "2024-02-20T11:51:46+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"psr-4": {
"Shalvah\\Upgrader\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Shalvah",
"email": "hello@shalvah.me"
}
],
"description": "Create automatic upgrades for your package.",
"homepage": "http://github.com/shalvah/upgrader",
"keywords": [
"upgrade"
],
"support": {
"issues": "https://github.com/shalvah/upgrader/issues",
"source": "https://github.com/shalvah/upgrader/tree/0.6.0"
},
"funding": [
{
"url": "https://patreon.com/shalvah",
"type": "patreon"
}
],
"install-path": "../shalvah/upgrader"
},
{
"name": "simplesoftwareio/simple-qrcode",
"version": "4.2.0",
@@ -14289,6 +14555,89 @@
],
"install-path": "../symfony/var-dumper"
},
{
"name": "symfony/var-exporter",
"version": "v8.0.8",
"version_normalized": "8.0.8.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-exporter.git",
"reference": "15776bb07a91b089037da89f8832fa41d5fa6ec6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/var-exporter/zipball/15776bb07a91b089037da89f8832fa41d5fa6ec6",
"reference": "15776bb07a91b089037da89f8832fa41d5fa6ec6",
"shasum": ""
},
"require": {
"php": ">=8.4"
},
"require-dev": {
"symfony/property-access": "^7.4|^8.0",
"symfony/serializer": "^7.4|^8.0",
"symfony/var-dumper": "^7.4|^8.0"
},
"time": "2026-03-30T15:14:47+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"psr-4": {
"Symfony\\Component\\VarExporter\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Allows exporting any serializable PHP data structure to plain PHP code",
"homepage": "https://symfony.com",
"keywords": [
"clone",
"construct",
"export",
"hydrate",
"instantiate",
"lazy-loading",
"proxy",
"serialize"
],
"support": {
"source": "https://github.com/symfony/var-exporter/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"install-path": "../symfony/var-exporter"
},
{
"name": "symfony/yaml",
"version": "v7.4.1",
@@ -14945,11 +15294,14 @@
"fakerphp/faker",
"filp/whoops",
"hamcrest/hamcrest-php",
"knuckleswtf/scribe",
"laravel/breeze",
"laravel/pint",
"laravel/sail",
"mockery/mockery",
"mpociot/reflection-docblock",
"nunomaduro/collision",
"parsedown/parsedown",
"phar-io/manifest",
"phar-io/version",
"phpunit/php-code-coverage",
@@ -14973,11 +15325,13 @@
"sebastian/recursion-context",
"sebastian/type",
"sebastian/version",
"shalvah/upgrader",
"spatie/backtrace",
"spatie/error-solutions",
"spatie/flare-client-php",
"spatie/ignition",
"spatie/laravel-ignition",
"symfony/var-exporter",
"symfony/yaml",
"theseer/tokenizer"
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
---
name: release-minor
description: Automates the process of tagging a new minor release for Scribe by analyzing commit messages, updating the changelog, and creating a GitHub release.
---
# Release Minor Version
This skill automates the process of tagging a new minor release for Scribe.
## Workflow
1. **Analyze Commits & Build Changelog**:
- Identify the last release tag (e.g., using `git tag --sort=-v:refname | head -n 1`).
- Get all commits from the last tag to `HEAD`.
- Analyze commit messages to categorize them into "Added", "Modified", "Fixed", or "Removed".
- Draft a new section for `CHANGELOG.md` following the existing format:
```markdown
## <New Version> (<Date>)
### Added
- [Description] ([#PR](link))
### Modified
- ...
```
2. **Update Files**:
- Prepend the new section to the top of the release list in `CHANGELOG.md`.
- Update the `public const VERSION` in `src/Scribe.php` to the new version number.
3. **Commit and Push**:
- Stage `CHANGELOG.md` and `src/Scribe.php`.
- Commit with the message: `Bump version to <New Version>`.
- Push the changes to the remote repository.
4. **Create GitHub Release**:
- Use the `gh` CLI to create a new release.
- Command: `gh release create <New Version> --title "<New Version>" --notes "<Changelog Content>"`
- Ensure the notes correspond exactly to the added changelog section.

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
# shared pre-commit hook to run pint
# Try several fallbacks so it works on Unix and common Windows setups.
# Prefer directly executable vendor binary
if [ -x "./vendor/bin/pint" ]; then
./vendor/bin/pint .
exit $?
fi
# If it's a PHP wrapper script, run with php
if [ -f "./vendor/bin/pint" ]; then
php "./vendor/bin/pint" .
exit $?
fi
# If a Windows batch exists under vendor/bin
if [ -x "./vendor/bin/pint.bat" ]; then
./vendor/bin/pint.bat .
exit $?
fi
if [ -f "./vendor/bin/pint.bat" ]; then
# Try running with cmd.exe for Windows Git environments
if command -v cmd.exe >/dev/null 2>&1; then
cmd.exe /C "vendor\\bin\\pint.bat fix"
exit $?
fi
fi
# Nothing to run; do not block the push but inform the user
echo "pint not found at ./vendor/bin/pint; skipping pre-push formatting."
exit 0

82
vendor/knuckleswtf/scribe/AGENTS.md vendored Normal file
View File

@@ -0,0 +1,82 @@
# Agent Guidelines for Scribe
This repository contains the source code for Scribe, a PHP package that generates API documentation for Laravel applications.
## 🛠 Project Tooling & Commands
This project uses **Composer** for dependency management and scripts.
- **Test Runner**: [Pest](https://pestphp.com/) (which wraps PHPUnit).
- **Static Analysis**: [PHPStan](https://phpstan.org/).
- **Code Style**: [PHP-CS-Fixer](https://cs.symfony.com/).
### Key Commands
| Action | Command | Description |
|--------|---------|-------------|
| **Run Tests** | `composer test` | Runs all tests using Pest. |
| **Run Single Test** | `vendor/bin/pest --filter MethodName` | Runs a specific test method. |
| **Run File Tests** | `vendor/bin/pest path/to/Test.php` | Runs all tests in a specific file. |
| **Lint Code** | `composer lint` | Runs PHPStan analysis. |
| **Check Style** | `composer style:check` | Checks code style without modifying files. |
| **Fix Style** | `composer style:fix` | Automatically fixes code style issues. |
> **Note**: Always run `composer test` and `composer lint` before submitting changes.
## 📐 Code Style & Conventions
Adhere strictly to the existing style. The project generally follows **PSR-12**.
### General PHP
- **Version**: PHP 8.1+. Use modern features like typed properties, union types, and constructor promotion where appropriate.
- **Strict Types**: Do **NOT** use `declare(strict_types=1);` unless strictly necessary or found in the file being edited (it is generally absent).
- **Indentation**: 4 spaces.
- **Arrays**: Use short array syntax `[]`.
- **Trailing Commas**: Use trailing commas in multi-line arrays.
### Naming
- **Classes**: `PascalCase` (e.g., `PostmanCollectionWriter`).
- **Methods**: `camelCase` (e.g., `generatePostmanCollection`).
- **Variables**: `camelCase` (e.g., `$groupedEndpoints`).
- **Constants**: `UPPER_CASE_WITH_UNDERSCORES`.
### Imports & Namespaces
- **Ordering**: Alphabetical order.
- **Grouping**: Group standard library imports separately if applicable, but generally keep them sorted.
- **Unused Imports**: Remove unused imports.
### Type Hinting & Docblocks
- **Types**: Use native PHP type hints (param and return types) whenever possible.
- Example: `public function writeDocs(array $groupedEndpoints): void`
- **Docblocks**:
- Use PHPDoc for generic arrays to specify content types (e.g., `/** @param array<string, mixed> $config */`).
- Use `/** @var ClassName $var */` for inline type hinting when the static analyzer cannot infer it (e.g., resolving from container).
- Do not duplicate native type info in docblocks unless adding detail.
### Laravel Integration
- **Dependency Injection**: Prefer constructor injection.
- **Helpers**: Use Laravel helpers (`app()`, `config()`, `public_path()`) where appropriate and consistent with existing code.
- **Facades**: Facades are used (e.g., `Storage`, `Route`). Import them via `Illuminate\Support\Facades\...`.
## 🧪 Testing Guidelines
- **Framework**: Tests are written using PHPUnit class syntax (extending `BaseLaravelTest` or `TestCase`), but executed via Pest.
- **Location**: Place unit tests in `tests/Unit/`.
- **Naming**: Test classes end in `Test.php`. Test methods use the `/** @test */` annotation or start with `test`.
- **Assertions**: Use standard PHPUnit assertions (e.g., `$this->assertEquals`, `$this->assertCount`).
- **Mocking**: Use Mockery or Laravel's mocking helpers if applicable.
## 📂 File Structure
- `src/`: Core package source code.
- `Writing/`: Logic for generating output files (HTML, Postman, OpenAPI).
- `Tools/`: Utility classes.
- `Attributes/`: PHP Attributes used by the package.
- `tests/`: Test suite.
- `resources/views/`: Blade templates for generated documentation.
- `config/`: Configuration files.
## 📝 Error Handling
- Use specific exceptions where possible (e.g., `ScribeException`).
- Handle errors gracefully, especially when parsing user code or configuration.
- Use `ConsoleOutputUtils` (aliased as `c`) for CLI output in commands.

115
vendor/knuckleswtf/scribe/CHANGELOG.md vendored Normal file
View File

@@ -0,0 +1,115 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project aims to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## <Version> (<Release date>)
### Fixed
### Modified
### Added
### Removed
## 5.9.0 (21 March 2026)
### Modified
- Add symfony/yaml v8 support ([#1063](https://github.com/knuckleswtf/scribe/pull/1063))
## 5.8.0 (23 February 2026)
### Added
- Allow opt in suppressing of model events when generating response data with factories ([#1057](https://github.com/knuckleswtf/scribe/pull/1057))
- Add agent skill for releasing (c56c096d)
### Modified
- Bump dependencies for Laravel 13 ([#1060](https://github.com/knuckleswtf/scribe/pull/1060))
### Fixed
- Fix undefined array key 'text' in MarkdownParser::blockHeader ([#1059](https://github.com/knuckleswtf/scribe/pull/1059))
## 5.7.0 (04 February 2026)
### Added
- Feature: laravel console output ([#1047](https://github.com/knuckleswtf/scribe/pull/1047))
- Add AGENTS.md for AI coding assistants ([#1055](https://github.com/knuckleswtf/scribe/pull/1055))
- Add OpenAPI 3.1 schema example to examples conversion ([#1042](https://github.com/knuckleswtf/scribe/pull/1042))
### Modified
- Replace erusev/parsedown with parsedown/parsedown for PHP 8.4 compatibility ([#1049](https://github.com/knuckleswtf/scribe/pull/1049))
- Use pint for code style ([#1054](https://github.com/knuckleswtf/scribe/pull/1054))
### Fixed
- Fix HTML special characters breaking Try It Out panel JSON display ([#1048](https://github.com/knuckleswtf/scribe/pull/1048))
- Respect wrapping of API resources ([#1046](https://github.com/knuckleswtf/scribe/pull/1046))
- Prevent empty enums when omitting ResponseField parameters ([#1044](https://github.com/knuckleswtf/scribe/pull/1044))
- Openapi nullable response fields ([#1041](https://github.com/knuckleswtf/scribe/pull/1041))
## 5.6.0 (23 November 2025)
- Add support for OpenAPI v3.1 specification ([#1040](https://github.com/knuckleswtf/scribe/pull/1040))
- Added `openapi.version` configuration option to choose between OpenAPI 3.0.3 (default) and 3.1.0
- OpenAPI 3.1 uses JSON Schema Draft 2020-12 compatible nullable syntax: `type: ["string", "null"]`
- OpenAPI 3.0 continues to use `nullable: true` property
- Fully backward compatible - defaults to 3.0.3 if not configured
## 5.5.0 (25 October 2025)
### Modified
- Change shalvah/clara constraint
### Fixed
- fix: handle rare edge case in base OpenApi spec generator after parallel merge ([#1031](https://github.com/knuckleswtf/scribe/pull/1031))
## 5.4.0 (21 October 2025)
### Fixed
- fix: arrays of objects support in OpenAPI response generation ([1021](https://github.com/knuckleswtf/scribe/pull/1021))
- fix: white space issue with headers displaying in endpoint view ([#1023](https://github.com/knuckleswtf/scribe/pull/1023))
- fix: crash when API resource __construct enforces type in ResponseFromApiResource ([#1028](https://github.com/knuckleswtf/scribe/pull/1028))
### Modified
- Replace abandoned spatie/data-transfer-object with own DTO implementation ([#1024](https://github.com/knuckleswtf/scribe/pull/1024))
### Added
- Support `deprecated:` option for `#[BodyParam]/#[QueryParam]` attributes ([#1022](https://github.com/knuckleswtf/scribe/pull/1022))
- Support for `sometimes` rule ([958](https://github.com/knuckleswtf/scribe/pull/958))
- Support for custom response Content-Types (problem+json, etc.) ([#1029](https://github.com/knuckleswtf/scribe/pull/1029))
- Support strings for the `#[Deprecated]` attribute ([#1019](https://github.com/knuckleswtf/scribe/pull/1019))
## 5.3.0 (29 July 2025)
### Added
- Support `@deprecated`/`[#Deprecated]` annotations for deprecating endpoints, along with deprecated badge in the included themes ([#994](https://github.com/knuckleswtf/scribe/pull/994))
- Add enum list to Open API spec response properties ([#902](https://github.com/knuckleswtf/scribe/pull/902))
### Fixed
- Format response codes as strings in OpenAPI spec ([80d21f1c46](https://github.com/knuckleswtf/scribe/commit/80d21f1c4678e44ba8e2e549f075e7b3bfd72fe5))
- Don't escape $baseUrl in view ([39695304c9c](https://github.com/knuckleswtf/scribe/commit/39695304c9cd75d627a4e8b59fe20b4636581066))
- Possible empty part of Route when path_param is not mandatory ([#992](https://github.com/knuckleswtf/scribe/pull/992))
- Postman collection generation failing due to invalid UTF-8 characters ([#997](https://github.com/knuckleswtf/scribe/pull/997))
- Use Recursive Spec Merge in OpenAPI SecurityGenerator & Fix OverrideGenerator base ([#1003](https://github.com/knuckleswtf/scribe/pull/1003))
### Changed
- Resolve fully qualified names ([#1008](https://github.com/knuckleswtf/scribe/pull/1008))
- Ensure Validator facade rules are detected ([#1006](https://github.com/knuckleswtf/scribe/pull/1006))
- Move intro_text directly after description in config/scribe.php for easier discovery ([#1001](https://github.com/knuckleswtf/scribe/pull/1001))
## 5.2.1 (1 May 2025)
### Added
- Fix regressions in parsing validation rules [a9e7a668d](https://github.com/knuckleswtf/scribe/commit/a9e7a668d7fa74ad8a1591e443db6600498238ef)
## 5.2.0 (17 April 2025)
### Added
- Fix breaking bugfix for validation rules (array of objects) in newer Laravel versions [03968babc9](https://github.com/knuckleswtf/scribe/commit/03968babc901d38a284d3569000205e7d38ba1e1)
### Fixed
- Avoid swallowing errors on example model instantiation (#964)[https://github.com/knuckleswtf/scribe/pull/964]
## 5.1.0 (25 February 2025)
### Added
- Support for streamed responses in response calls [790ad94e512](https://github.com/knuckleswtf/scribe/commit/790ad94e512d987feae6f0443835d8cf8de64f53)
### Fixed
- Fixed use of `URL::useOrigin` vs `URL::forceRootURL` [956e9bf418](https://github.com/knuckleswtf/scribe/commit/956e9bf418f5fc06fe70009e476b1e8524aff5b1)
## 5.0.1 (20 February 2025)
### Fixed
- Fix bug in wrongly trying to determine required fields for array of strings [#951](https://github.com/knuckleswtf/scribe/pull/951)
## 5.0.0 (19 February 2025)
See the [migration guide](https://scribe.knuckles.wtf/migrating).

View File

@@ -0,0 +1,2 @@
# Contributing
Please see [our contribution guide](https://scribe.knuckles.wtf/laravel/contributing)

21
vendor/knuckleswtf/scribe/LICENSE.md vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2022 Shalvah
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

34
vendor/knuckleswtf/scribe/README.md vendored Normal file
View File

@@ -0,0 +1,34 @@
<div align="center">
<img src="logo-scribe.png" alt="logo-scribe"><br>
[![Latest Stable Version](https://poser.pugx.org/knuckleswtf/scribe/v/stable)](https://packagist.org/packages/knuckleswtf/scribe)
[![Total Downloads](https://poser.pugx.org/knuckleswtf/scribe/downloads)](https://packagist.org/packages/knuckleswtf/scribe)
[![Code Style](https://img.shields.io/badge/code_style-pint-f58d33)](https://github.com/laravel/pint)
</div>
Scribe helps you generate API documentation for humans from your Laravel codebase. See a live example at [demo.scribe.knuckles.wtf](https://demo.scribe.knuckles.wtf).
## Features
- Useful output:
- Pretty single-page HTML doc, with human-friendly text, code samples, and in-browser API tester ("Try It Out")
- Generates Postman collection and OpenAPI spec (v3.0.3 or v3.1.0)
- Smarts. Scribe can:
- extract request parameter details from FormRequests or validation rules
- safely call API endpoints to get sample responses
- generate sample responses from Eloquent API Resources or Transformers
- Customizable to different levels:
- Customize the UI by adjusting text, ordering, examples, or changing the UI itself
- Add custom strategies to adjust how data is extracted
- Statically define extra endpoints or information not in your codebase
> [!TIP]
> 👋 Scribe helps you generate docs automatically, but if you really want to make friendly, maintainable, and testable API docs, there are some more things you need to know.
> So I made [a course](https://shalvah.teachable.com/p/api-documentation-for-developers?utm_source=scribe-laravel&utm_medium=referral&utm_campaign=none) for you.🤗
## Documentation
Check out the documentation at [scribe.knuckles.wtf/laravel](http://scribe.knuckles.wtf/laravel).
## Contributing
Contributing is easy! See our [contribution guide](https://scribe.knuckles.wtf/laravel/contributing).

View File

@@ -0,0 +1,172 @@
<?php
namespace Knuckles\Camel;
use Illuminate\Contracts\Support\Arrayable;
class BaseDTO implements \ArrayAccess, Arrayable
{
/**
* @var array
* Added so end-users can dynamically add additional properties for their own use
*/
public array $custom = [];
public function __construct(array $parameters = [])
{
// Initialize all properties to their default values first
$this->initializeProperties();
foreach ($parameters as $key => $value) {
if (property_exists($this, $key)) {
$this->{$key} = $this->castProperty($key, $value);
}
}
}
public static function create(array|self $data, array|self $inheritFrom = []): static
{
if ($data instanceof static) {
return $data;
}
$mergedData = $inheritFrom instanceof static ? $inheritFrom->toArray() : $inheritFrom;
foreach ($data as $property => $value) {
$mergedData[$property] = $value;
}
return new static($mergedData);
}
public function toArray(): array
{
$array = [];
foreach (get_object_vars($this) as $property => $value) {
$array[$property] = $value;
}
return $this->parseArray($array);
}
public static function make(array|self $data): static
{
return $data instanceof static ? $data : new static($data);
}
public function offsetExists(mixed $offset): bool
{
return isset($this->{$offset});
}
public function offsetGet(mixed $offset): mixed
{
return $this->{$offset};
}
public function offsetSet(mixed $offset, mixed $value): void
{
$this->{$offset} = $value;
}
public function offsetUnset(mixed $offset): void
{
unset($this->{$offset});
}
public function except(string ...$keys): array
{
$array = [];
foreach (get_object_vars($this) as $property => $value) {
if (! in_array($property, $keys)) {
$array[$property] = $value;
}
}
return $this->parseArray($array);
}
public static function arrayOf(array $items): array
{
return array_map(function ($item) {
return $item instanceof static ? $item : new static($item);
}, $items);
}
protected function initializeProperties(): void
{
$reflection = new \ReflectionClass($this);
foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
$name = $property->getName();
// Skip if already initialized (has a default value)
if ($property->hasDefaultValue()) {
continue;
}
$type = $property->getType();
if ($type && $type->allowsNull()) {
$this->{$name} = null;
}
}
}
protected function castProperty(string $key, mixed $value): mixed
{
// If the value is already the correct type, return it as-is
if (! is_array($value)) {
return $value;
}
// Get property type through reflection
$reflection = new \ReflectionClass($this);
if (! $reflection->hasProperty($key)) {
return $value;
}
$property = $reflection->getProperty($key);
$type = $property->getType();
if ($type && $type instanceof \ReflectionNamedType && ! $type->isBuiltin()) {
$className = $type->getName();
// If it's a DTO class in our namespace, instantiate it
if (class_exists($className) && is_subclass_of($className, self::class)) {
return new $className($value);
}
// If it's another class in our namespace that has a constructor accepting arrays
if (class_exists($className)) {
try {
return new $className($value);
} catch (\Throwable $e) {
// If instantiation fails, return the original value
return $value;
}
}
}
return $value;
}
protected function parseArray(array $array): array
{
// Reimplementing here so our DTOCollection items can be recursively toArray'ed
foreach ($array as $key => $value) {
if ($value instanceof Arrayable) {
$array[$key] = $value->toArray();
continue;
}
if (! is_array($value)) {
continue;
}
$array[$key] = $this->parseArray($value);
}
return $array;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Knuckles\Camel;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;
/**
* @template T of \Knuckles\Camel\BaseDTO
*/
class BaseDTOCollection extends Collection
{
/**
* @var string the name of the base DTO class
*/
public static string $base = '';
public function __construct($items = [])
{
// Manually cast nested arrays
$items = array_map(
fn ($item) => is_array($item) ? new static::$base($item) : $item,
$items instanceof Collection ? $items->toArray() : $items
);
parent::__construct($items);
}
/**
* Append items to the collection, mutating it.
*
* @param array[]|T[] $items
*/
public function concat($items)
{
foreach ($items as $item) {
$this->push(is_array($item) ? new static::$base($item) : $item);
}
return $this;
}
public function toArray(): array
{
return array_map(
fn ($item) => $item instanceof Arrayable ? $item->toArray() : $item,
$this->items
);
}
}

View File

@@ -0,0 +1,231 @@
<?php
namespace Knuckles\Camel;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Knuckles\Camel\Output\OutputEndpointData;
use Knuckles\Scribe\Tools\PathConfig;
use Knuckles\Scribe\Tools\Utils;
use Symfony\Component\Yaml\Yaml;
class Camel
{
public static function cacheDir(PathConfig $paths): string
{
return $paths->intermediateOutputPath('endpoints.cache');
}
public static function camelDir(PathConfig $paths): string
{
return $paths->intermediateOutputPath('endpoints');
}
/**
* Load endpoints from the Camel files into groups (arrays).
*
* @return array[] each array is a group with keys including `name` and `endpoints`
*/
public static function loadEndpointsIntoGroups(string $folder): array
{
$groups = [];
self::loadEndpointsFromCamelFiles($folder, function (array $group) use (&$groups) {
$groups[$group['name']] = $group;
});
return $groups;
}
/**
* Load endpoints from the Camel files into a flat list of endpoint arrays.
* Useful when we don't care about groups, but simply want to compare endpoints contents
* to see if anything changed.
*
* @return array[] list of endpoint arrays
*/
public static function loadEndpointsToFlatPrimitivesArray(string $folder): array
{
$endpoints = [];
self::loadEndpointsFromCamelFiles($folder, function (array $group) use (&$endpoints) {
foreach ($group['endpoints'] as $endpoint) {
$endpoints[] = $endpoint;
}
});
return $endpoints;
}
public static function loadEndpointsFromCamelFiles(string $folder, callable $callback): void
{
$contents = Utils::listDirectoryContents($folder);
foreach ($contents as $object) {
if (
$object->isFile()
&& Str::endsWith(basename($object->path()), '.yaml')
&& ! Str::startsWith(basename($object->path()), 'custom.')
) {
$group = Yaml::parseFile($object['path']);
$callback($group);
}
}
}
public static function loadUserDefinedEndpoints(string $folder): array
{
$contents = Utils::listDirectoryContents($folder);
$userDefinedEndpoints = [];
foreach ($contents as $object) {
if (
$object->isFile()
&& Str::endsWith(basename($object->path()), '.yaml')
&& Str::startsWith(basename($object->path()), 'custom.')
) {
$endpoints = Yaml::parseFile($object->path());
foreach (($endpoints ?: []) as $endpoint) {
$userDefinedEndpoints[] = $endpoint;
}
}
}
return $userDefinedEndpoints;
}
public static function doesGroupContainEndpoint(array $group, OutputEndpointData $endpoint): bool
{
return (bool) (Arr::first($group['endpoints'], function ($e) use ($endpoint) {
return $e->endpointId() === $endpoint->endpointId();
}));
}
/**
* @param array[] $groupedEndpoints
* @param array $configFileOrder the order for groups that users specified in their config file
* @return array[]
*/
public static function sortByConfigFileOrder(array $groupedEndpoints, array $configFileOrder): array
{
if (empty($configFileOrder)) {
ksort($groupedEndpoints, SORT_NATURAL);
return $groupedEndpoints;
}
// First, sort groups
$groupsOrder = Utils::getTopLevelItemsFromMixedConfigList($configFileOrder);
$groupsCollection = collect($groupedEndpoints);
$wildcardPosition = array_search('*', $groupsOrder);
if ($wildcardPosition !== false) {
$promotedGroups = array_splice($groupsOrder, 0, $wildcardPosition);
$demotedGroups = array_splice($groupsOrder, 1);
$promotedOrderedGroups = $groupsCollection->filter(fn ($group, $groupName) => in_array($groupName, $promotedGroups))
->sortKeysUsing(self::getOrderListComparator($promotedGroups));
$demotedOrderedGroups = $groupsCollection->filter(fn ($group, $groupName) => in_array($groupName, $demotedGroups))
->sortKeysUsing(self::getOrderListComparator($demotedGroups));
$nonWildcardGroups = array_merge($promotedGroups, $demotedGroups);
$wildCardOrderedGroups = $groupsCollection->filter(fn ($group, $groupName) => ! in_array($groupName, $nonWildcardGroups))
->sortKeysUsing(self::getOrderListComparator($demotedGroups));
$groupedEndpoints = $promotedOrderedGroups->merge($wildCardOrderedGroups)
->merge($demotedOrderedGroups);
} else {
$groupedEndpoints = $groupsCollection->sortKeysUsing(self::getOrderListComparator($groupsOrder));
}
return $groupedEndpoints->map(function (array $group, string $groupName) use ($configFileOrder) {
$sortedEndpoints = collect($group['endpoints']);
if (isset($configFileOrder[$groupName])) {
// Second-level order list. Can contain endpoint or subgroup names
$level2Order = Utils::getTopLevelItemsFromMixedConfigList($configFileOrder[$groupName]);
$sortedEndpoints = $sortedEndpoints->sortBy(
function (OutputEndpointData $e) use ($configFileOrder, $level2Order) {
$endpointIdentifier = $e->httpMethods[0].' /'.$e->uri;
// First, check if there's an ordering specified for the endpoint itself
$indexOfEndpointInL2Order = array_search($endpointIdentifier, $level2Order);
if ($indexOfEndpointInL2Order !== false) {
return $indexOfEndpointInL2Order;
}
// Check if there's an ordering for the endpoint's subgroup
$indexOfSubgroupInL2Order = array_search($e->metadata->subgroup, $level2Order);
if ($indexOfSubgroupInL2Order !== false) {
// There's a subgroup order; check if there's an endpoints order within that
$orderOfEndpointsInSubgroup = $configFileOrder[$e->metadata->groupName][$e->metadata->subgroup] ?? [];
$indexOfEndpointInSubGroup = array_search($endpointIdentifier, $orderOfEndpointsInSubgroup);
return ($indexOfEndpointInSubGroup === false)
? $indexOfSubgroupInL2Order
: ($indexOfSubgroupInL2Order + ($indexOfEndpointInSubGroup * 0.1));
}
return INF;
},
);
}
return [
'name' => $groupName,
'description' => $group['description'],
'endpoints' => $sortedEndpoints->all(),
];
})->values()->all();
}
/**
* Prepare endpoints to be turned into HTML.
* Map them into OutputEndpointData DTOs, and sort them by the specified order in the config file.
*
* @param array<string,array[]> $groupedEndpoints
*/
public static function prepareGroupedEndpointsForOutput(array $groupedEndpoints, array $configFileOrder = []): array
{
$groups = array_map(function (array $group) {
return [
'name' => $group['name'],
'description' => $group['description'],
'endpoints' => array_map(
fn (array $endpoint) => OutputEndpointData::fromExtractedEndpointArray($endpoint),
$group['endpoints']
),
];
}, $groupedEndpoints);
return self::sortByConfigFileOrder($groups, $configFileOrder);
}
/**
* Given an $order list like ['first', 'second', ...], return a compare function that can be used to sort
* a list of strings based on the order of items in $order.
* Any strings not in the list are sorted with natural sort.
*/
protected static function getOrderListComparator(array $order): \Closure
{
return function ($a, $b) use ($order) {
$indexOfA = array_search($a, $order);
$indexOfB = array_search($b, $order);
// If both are in the $order list, compare them normally based on their position in the list
if ($indexOfA !== false && $indexOfB !== false) {
return $indexOfA <=> $indexOfB;
}
// If only A is in the $order list, then it must come before B.
if ($indexOfA !== false) {
return -1;
}
// If only B is in the $order list, then it must come before A.
if ($indexOfB !== false) {
return 1;
}
// If neither is present, fall back to natural sort
return strnatcmp($a, $b);
};
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace Knuckles\Camel\Extraction;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route;
use Knuckles\Camel\BaseDTO;
use Knuckles\Scribe\Extracting\Shared\UrlParamsNormalizer;
use Knuckles\Scribe\Tools\Globals;
use Knuckles\Scribe\Tools\Utils as u;
class ExtractedEndpointData extends BaseDTO
{
/**
* @var array<string>
*/
public array $httpMethods;
public string $uri;
public Metadata $metadata;
/**
* @var array<string,string>
*/
public array $headers = [];
/**
* @var array<string,Parameter>
*/
public array $urlParameters = [];
/**
* @var array<string,mixed>
*/
public array $cleanUrlParameters = [];
/**
* @var array<string,Parameter>
*/
public array $queryParameters = [];
/**
* @var array<string,mixed>
*/
public array $cleanQueryParameters = [];
/**
* @var array<string,Parameter>
*/
public array $bodyParameters = [];
/**
* @var array<string,mixed>
*/
public array $cleanBodyParameters = [];
/**
* @var array<string,array|UploadedFile>
*/
public array $fileParameters = [];
public ResponseCollection $responses;
/**
* @var array<string,ResponseField>
*/
public array $responseFields = [];
/**
* Authentication info for this endpoint. In the form [{where}, {name}, {sample}]
* Example: ["queryParameters", "api_key", "njiuyiw97865rfyvgfvb1"].
*/
public array $auth = [];
public ?\ReflectionClass $controller;
public ?\ReflectionFunctionAbstract $method;
public ?Route $route;
public function __construct(array $parameters = [])
{
$parameters['metadata'] ??= new Metadata([]);
$parameters['responses'] ??= new ResponseCollection([]);
parent::__construct($parameters);
$defaultNormalizer = fn () => UrlParamsNormalizer::normalizeParameterNamesInRouteUri($this->route, $this->method);
$this->uri = match (is_callable(Globals::$__normalizeEndpointUrlUsing)) {
true => call_user_func_array(
Globals::$__normalizeEndpointUrlUsing,
[$this->route->uri, $this->route, $this->method, $this->controller, $defaultNormalizer]
),
default => $defaultNormalizer(),
};
}
/**
* @param array $extras Only used for quick overrides in tests
*
* @throws \ReflectionException
*/
public static function fromRoute(Route $route, array $extras = []): self
{
$httpMethods = self::getMethods($route);
$uri = $route->uri();
[$controllerName, $methodName] = u::getRouteClassAndMethodNames($route);
$controller = new \ReflectionClass($controllerName);
$method = u::getReflectedRouteMethod([$controllerName, $methodName]);
$data = compact('httpMethods', 'uri', 'controller', 'method', 'route');
$data = array_merge($data, $extras);
return new self($data);
}
/**
* @return array<string>
*/
public static function getMethods(Route $route): array
{
$methods = $route->methods();
// Laravel adds an automatic "HEAD" endpoint for each GET request, so we'll strip that out,
// but not if there's only one method (means it was intentional)
if (count($methods) === 1) {
return $methods;
}
return array_diff($methods, ['HEAD']);
}
public function name()
{
return sprintf("[%s] {$this->route->uri}.", implode(',', $this->route->methods));
}
public function endpointId()
{
return $this->httpMethods[0].str_replace(['/', '?', '{', '}', ':', '\\', '+', '|'], '-', $this->uri);
}
/**
* Prepare the endpoint data for serialising.
*/
public function forSerialisation()
{
$copyArray = $this->except(
// Get rid of all duplicate data
'cleanQueryParameters',
'cleanUrlParameters',
'fileParameters',
'cleanBodyParameters',
// and objects used only in extraction
'route',
'controller',
'method',
'auth',
);
// Remove these, since they're on the parent group object
if (isset($copyArray['metadata']) && $copyArray['metadata'] instanceof Metadata) {
$copyArray['metadata'] = $copyArray['metadata']->except('groupName', 'groupDescription');
}
return $copyArray;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Knuckles\Camel\Extraction;
use Knuckles\Camel\BaseDTO;
class Metadata extends BaseDTO
{
public ?string $groupName;
public ?string $groupDescription;
public ?string $subgroup;
public ?string $subgroupDescription;
public ?string $title;
public ?string $description;
public bool $authenticated = false;
public bool|string $deprecated = false;
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Knuckles\Camel\Extraction;
use Knuckles\Camel\BaseDTO;
class Parameter extends BaseDTO
{
public string $name;
public ?string $description = null;
public bool $required = false;
public mixed $example = null;
public string $type = 'string';
public array $enumValues = [];
public bool $exampleWasSpecified = false;
public bool $nullable = false;
public bool $deprecated = false;
public function __construct(array $parameters = [])
{
unset($parameters['setter']);
parent::__construct($parameters);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Knuckles\Camel\Extraction;
use Illuminate\Support\Str;
use Knuckles\Camel\BaseDTO;
class Response extends BaseDTO
{
public int $status;
public ?string $content;
public array $headers = [];
public ?string $description;
public function __construct(array $parameters = [])
{
if (is_array($parameters['content'] ?? null)) {
$parameters['content'] = json_encode($parameters['content'], JSON_UNESCAPED_SLASHES);
}
if (isset($parameters['status'])) {
$parameters['status'] = (int) $parameters['status'];
}
$hiddenHeaders = [
'date',
'Date',
'etag',
'ETag',
'last-modified',
'Last-Modified',
'date',
'Date',
'content-length',
'Content-Length',
'connection',
'Connection',
'x-powered-by',
'X-Powered-By',
];
if (! empty($parameters['headers'])) {
foreach ($hiddenHeaders as $headerName) {
unset($parameters['headers'][$headerName]);
}
}
parent::__construct($parameters);
}
public function fullDescription()
{
$description = $this->status;
if ($this->description) {
$description .= ", {$this->description}";
}
return $description;
}
public function isBinary(): bool
{
return is_string($this->content) && Str::startsWith($this->content, '<<binary>>');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Knuckles\Camel\Extraction;
use Knuckles\Camel\BaseDTOCollection;
/**
* @extends BaseDTOCollection<Response>
*/
class ResponseCollection extends BaseDTOCollection
{
public static string $base = Response::class;
public function hasSuccessResponse(): bool
{
return $this->first(
fn ($response) => '2' === ((string) ($response->status))[0]
) !== null;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Knuckles\Camel\Extraction;
use Knuckles\Camel\BaseDTO;
class ResponseField extends BaseDTO
{
// TODO make this extend Parameter, so we can have strong types and a unified API
// but first we need to normalize incoming data
/** @var string */
public $name;
/** @var string */
public $description;
/** @var string */
public $type;
/** @var bool */
public $required;
/** @var mixed */
public $example;
public array $enumValues = [];
/** @var bool */
public $nullable;
}

View File

@@ -0,0 +1,355 @@
<?php
namespace Knuckles\Camel\Output;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Knuckles\Camel\BaseDTO;
use Knuckles\Camel\Extraction\Metadata;
use Knuckles\Camel\Extraction\ResponseCollection;
use Knuckles\Camel\Extraction\ResponseField;
use Knuckles\Scribe\Extracting\Extractor;
use Knuckles\Scribe\Tools\Utils as u;
use Knuckles\Scribe\Tools\WritingUtils;
/**
* Endpoint DTO, optimized for generating HTML output.
* Unneeded properties removed, extra properties and helper methods added.
*/
class OutputEndpointData extends BaseDTO
{
/**
* @var array<string>
*/
public array $httpMethods;
public string $uri;
public Metadata $metadata;
/**
* @var array<string,string>
*/
public array $headers = [];
/**
* @var array<string,Parameter>
*/
public array $urlParameters = [];
/**
* @var array<string,mixed>
*/
public array $cleanUrlParameters = [];
/**
* @var array<string,Parameter>
*/
public array $queryParameters = [];
/**
* @var array<string,mixed>
*/
public array $cleanQueryParameters = [];
/**
* @var array<string, Parameter>
*/
public array $bodyParameters = [];
/**
* @var array<string,mixed>
*/
public array $cleanBodyParameters = [];
/**
* @var array<string,UploadedFile>
*/
public array $fileParameters = [];
public ResponseCollection $responses;
/**
* @var array<string,ResponseField>
*/
public array $responseFields = [];
/**
* The same as bodyParameters, but organized in a hierarchy.
* So, top-level items first, with a __fields property containing their children, and so on.
* Useful so we can easily render and nest on the frontend.
*
* @var array<string, array>
*/
public array $nestedBodyParameters = [];
/**
* @var array<string, array>
*/
public array $nestedResponseFields = [];
public ?string $boundUri;
public function __construct(array $parameters = [])
{
// spatie/dto currently doesn't auto-cast nested DTOs like that
$parameters['responses'] = new ResponseCollection($parameters['responses'] ?? []);
$parameters['bodyParameters'] = array_map(fn ($param) => new Parameter($param), $parameters['bodyParameters'] ?? []);
$parameters['queryParameters'] = array_map(fn ($param) => new Parameter($param), $parameters['queryParameters'] ?? []);
$parameters['urlParameters'] = array_map(fn ($param) => new Parameter($param), $parameters['urlParameters'] ?? []);
$parameters['responseFields'] = array_map(fn ($param) => new ResponseField($param), $parameters['responseFields'] ?? []);
parent::__construct($parameters);
$this->cleanBodyParameters = Extractor::cleanParams($this->bodyParameters);
$this->cleanQueryParameters = Extractor::cleanParams($this->queryParameters);
$this->cleanUrlParameters = Extractor::cleanParams($this->urlParameters);
$this->nestedBodyParameters = self::nestArrayAndObjectFields($this->bodyParameters, $this->cleanBodyParameters);
$this->nestedResponseFields = self::nestArrayAndObjectFields($this->responseFields);
$this->boundUri = u::getUrlWithBoundParameters($this->uri, $this->cleanUrlParameters);
[$files, $regularParameters] = static::splitIntoFileAndRegularParameters($this->cleanBodyParameters);
if (count($files)) {
$this->headers['Content-Type'] = 'multipart/form-data';
}
$this->fileParameters = $files;
$this->cleanBodyParameters = $regularParameters;
}
/**
* @return array<string>
*/
public static function getMethods(Route $route): array
{
$methods = $route->methods();
// Laravel adds an automatic "HEAD" endpoint for each GET request, so we'll strip that out,
// but not if there's only one method (means it was intentional)
if (count($methods) === 1) {
return $methods;
}
return array_diff($methods, ['HEAD']);
}
public static function fromExtractedEndpointArray(array $endpoint): self
{
return new self($endpoint);
}
/**
* Transform body parameters such that object fields have a `fields` property containing a list of all subfields
* Subfields will be removed from the main parameter map
* For instance, if $parameters is [
* 'dad' => new Parameter(...),
* 'dad.age' => new Parameter(...),
* 'dad.cars[]' => new Parameter(...),
* 'dad.cars[].model' => new Parameter(...),
* 'dad.cars[].price' => new Parameter(...),
* ],
* normalise this into [
* 'dad' => [
* ...,
* '__fields' => [
* 'dad.age' => [...],
* 'dad.cars' => [
* ...,
* '__fields' => [
* 'model' => [...],
* 'price' => [...],
* ],
* ],
* ],
* ]].
*/
public static function nestArrayAndObjectFields(array $parameters, array $cleanParameters = []): array
{
// First, we'll make sure all object fields have parent fields properly set
$normalisedParameters = [];
foreach ($parameters as $name => $parameter) {
if (Str::contains($name, '.')) {
// If the user didn't add a parent field, we'll helpfully add it for them
$ancestors = [];
$parts = explode('.', $name);
$fieldName = array_pop($parts);
$parentName = mb_rtrim(implode('.', $parts), '[]');
// When the body is an array, param names will be "[].paramname",
// so $parentName is empty. Let's fix that.
if (empty($parentName)) {
$parentName = '[]';
}
while ($parentName) {
if (! empty($normalisedParameters[$parentName])) {
break;
}
$details = [
'name' => $parentName,
'type' => $parentName === '[]' ? 'object[]' : 'object',
'description' => '',
'required' => false,
];
if ($parameter instanceof ResponseField) {
$ancestors[] = [$parentName, new ResponseField($details)];
} else {
$lastParentExample = $details['example']
= [$fieldName => $lastParentExample ?? $parameter->example];
$ancestors[] = [$parentName, new Parameter($details)];
}
$fieldName = array_pop($parts);
$parentName = mb_rtrim(implode('.', $parts), '[]');
}
// We add ancestors in reverse so we can iterate over parents first in the next section
foreach (array_reverse($ancestors) as [$ancestorName, $ancestor]) {
$normalisedParameters[$ancestorName] = $ancestor;
}
}
$normalisedParameters[$name] = $parameter;
unset($lastParentExample);
}
$finalParameters = [];
foreach ($normalisedParameters as $name => $parameter) {
$parameter = $parameter->toArray();
if (Str::contains($name, '.')) { // An object field
// Get the various pieces of the name
$parts = explode('.', $name);
$fieldName = array_pop($parts);
$baseName = implode('.__fields.', $parts);
// For subfields, the type is indicated in the source object
// eg test.items[].more and test.items.more would both have parent field with name `items` and containing __fields => more
// The difference would be in the parent field's `type` property (object[] vs object)
// So we can get rid of all [] to get the parent name
$dotPathToParent = str_replace('[]', '', $baseName);
// When the body is an array, param names will be "[].paramname",
// so $parts is ['[]']
if ($parts[0] === '[]') {
$dotPathToParent = '[]'.$dotPathToParent;
}
$dotPath = $dotPathToParent.'.__fields.'.$fieldName;
Arr::set($finalParameters, $dotPath, $parameter);
} else { // A regular field, not a subfield of anything
// Note: we're assuming any subfields of this field are listed *after* it,
// and will set __fields correctly when we iterate over them
// Hence why we create a new "normalisedParameters" array above and push the parent to that first
$parameter['__fields'] = [];
$finalParameters[$name] = $parameter;
}
}
// Finally, if the body is an array, remove any other items.
if (isset($finalParameters['[]'])) {
$finalParameters = ['[]' => $finalParameters['[]']];
// At this point, the examples are likely [[], []],
// but have been correctly set in clean parameters, so let's update them
if ($finalParameters['[]']['example'][0] === [] && ! empty($cleanParameters)) {
$finalParameters['[]']['example'] = $cleanParameters;
}
}
return $finalParameters;
}
public function endpointId(): string
{
return $this->httpMethods[0].str_replace(['/', '?', '{', '}', ':', '\\', '+', '|', '.'], '-', $this->uri);
}
public function name(): string
{
return $this->metadata->title ?: ($this->httpMethods[0].' '.$this->uri);
}
public function fullSlug(): string
{
$groupSlug = Str::slug($this->metadata->groupName);
$endpointId = $this->endpointId();
return "{$groupSlug}-{$endpointId}";
}
public function hasResponses(): bool
{
return count($this->responses) > 0;
}
public function hasFiles(): bool
{
return count($this->fileParameters) > 0;
}
public function isArrayBody(): bool
{
return count($this->nestedBodyParameters) === 1
&& array_keys($this->nestedBodyParameters)[0] === '[]';
}
public function isGet(): bool
{
return in_array('GET', $this->httpMethods);
}
public function isAuthed(): bool
{
return $this->metadata->authenticated;
}
public function hasJsonBody(): bool
{
if ($this->hasFiles() || empty($this->nestedBodyParameters)) {
return false;
}
$contentType = data_get($this->headers, 'Content-Type', data_get($this->headers, 'content-type', ''));
return str_contains($contentType, 'json');
}
public function getSampleBody()
{
return WritingUtils::getSampleBody($this->nestedBodyParameters);
}
public function hasHeadersOrQueryOrBodyParams(): bool
{
return ! empty($this->headers)
|| ! empty($this->cleanQueryParameters)
|| ! empty($this->cleanBodyParameters);
}
public static function splitIntoFileAndRegularParameters(array $parameters): array
{
$files = [];
$regularParameters = [];
foreach ($parameters as $name => $example) {
if ($example instanceof UploadedFile) {
$files[$name] = $example;
} elseif (is_array($example) && ! empty($example)) {
[$subFiles, $subRegulars] = static::splitIntoFileAndRegularParameters($example);
foreach ($subFiles as $subName => $subExample) {
$files[$name][$subName] = $subExample;
}
foreach ($subRegulars as $subName => $subExample) {
$regularParameters[$name][$subName] = $subExample;
}
} else {
$regularParameters[$name] = $example;
}
}
return [$files, $regularParameters];
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Knuckles\Camel\Output;
class Parameter extends \Knuckles\Camel\Extraction\Parameter
{
public array $__fields = [];
public function toArray(): array
{
return $this->except('__fields');
}
}

98
vendor/knuckleswtf/scribe/composer.json vendored Normal file
View File

@@ -0,0 +1,98 @@
{
"name": "knuckleswtf/scribe",
"description": "Generate API documentation for humans from your Laravel codebase.✍",
"license": "MIT",
"keywords": [
"API",
"documentation",
"laravel"
],
"authors": [
{
"name": "Shalvah"
}
],
"homepage": "https://github.com/knuckleswtf/scribe",
"funding": [
{
"type": "patreon",
"url": "https://patreon.com/shalvah"
}
],
"require": {
"php": ">=8.1",
"ext-fileinfo": "*",
"ext-json": "*",
"ext-pdo": "*",
"fakerphp/faker": "^1.23.1",
"laravel/framework": "^9.21 || ^10.0 || ^11.0 || ^12.0 || ^13.0",
"league/flysystem": "^3.0",
"mpociot/reflection-docblock": "^1.0.1",
"nikic/php-parser": "^5.0",
"nunomaduro/collision": "^6.0 || ^7.0 || ^8.0",
"parsedown/parsedown": "^1.7",
"ramsey/uuid": "^4.2.2",
"shalvah/upgrader": "^0.6.0",
"symfony/var-exporter": "^6.0 || ^7.0 || ^8.0",
"symfony/yaml": "^6.0 || ^7.0 || ^8.0"
},
"require-dev": {
"dms/phpunit-arraysubset-asserts": "^0.5.0",
"laravel/legacy-factories": "^1.3.0",
"laravel/pint": "^1.20",
"league/fractal": "^0.20",
"nikic/fast-route": "^1.3",
"orchestra/testbench": "^7.0 || ^8.0 || ^9.10 || ^10.0 || ^11.0",
"pestphp/pest": "^1.21 || ^2.0 || ^3.0 || ^4.0",
"phpstan/phpstan": "^2.1.5",
"phpunit/phpunit": "^9.0 || ^10.0 || ^11.0 || ^12.0",
"spatie/ray": "^1.41",
"symfony/css-selector": "^6.0 || ^7.0 || ^8.0",
"symfony/dom-crawler": "^6.0 || ^7.0 || ^8.0"
},
"replace": {
"mpociot/laravel-apidoc-generator": "*"
},
"autoload": {
"psr-4": {
"Knuckles\\Camel\\": "camel/",
"Knuckles\\Scribe\\": "src/"
},
"files": [
"src/Config/helpers.php"
]
},
"autoload-dev": {
"psr-4": {
"Knuckles\\Scribe\\Tests\\": "tests/"
}
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
},
"preferred-install": "dist",
"process-timeout": 600,
"sort-packages": true
},
"extra": {
"laravel": {
"providers": [
"Knuckles\\Scribe\\ScribeServiceProvider"
]
}
},
"scripts": {
"post-install-cmd": [
"git config core.hooksPath .githooks"
],
"post-update-cmd": [
"git config core.hooksPath .githooks"
],
"lint": "phpstan analyse -c ./phpstan.neon src camel --memory-limit 1G",
"style:check": "pint . --test --parallel --ansi",
"style:fix": "pint . --parallel --ansi",
"test": "pest --stop-on-failure --colors",
"test-ci": "pest"
}
}

View File

@@ -0,0 +1,259 @@
<?php
use Knuckles\Scribe\Config\AuthIn;
use Knuckles\Scribe\Config\Defaults;
use Knuckles\Scribe\Extracting\Strategies;
use function Knuckles\Scribe\Config\configureStrategy;
use function Knuckles\Scribe\Config\removeStrategies;
// Only the most common configs are shown. See the https://scribe.knuckles.wtf/laravel/reference/config for all.
return [
// The HTML <title> for the generated documentation.
'title' => config('app.name').' API Documentation',
// A short description of your API. Will be included in the docs webpage, Postman collection and OpenAPI spec.
'description' => '',
// Text to place in the "Introduction" section, right after the `description`. Markdown and HTML are supported.
'intro_text' => <<<'INTRO'
This documentation aims to provide all the information you need to work with our API.
<aside>As you scroll, you'll see code examples for working with the API in different programming languages in the dark area to the right (or as part of the content on mobile).
You can switch the language used with the tabs at the top right (or from the nav menu at the top left on mobile).</aside>
INTRO,
// The base URL displayed in the docs.
// If you're using `laravel` type, you can set this to a dynamic string, like '{{ config("app.tenant_url") }}' to get a dynamic base URL.
'base_url' => config('app.url'),
// Routes to include in the docs
'routes' => [
[
'match' => [
// Match only routes whose paths match this pattern (use * as a wildcard to match any characters). Example: 'users/*'.
'prefixes' => ['api/*'],
// Match only routes whose domains match this pattern (use * as a wildcard to match any characters). Example: 'api.*'.
'domains' => ['*'],
],
// Include these routes even if they did not match the rules above.
'include' => [
// 'users.index', 'POST /new', '/auth/*'
],
// Exclude these routes even if they matched the rules above.
'exclude' => [
// 'GET /health', 'admin.*'
],
],
],
// The type of documentation output to generate.
// - "static" will generate a static HTMl page in the /public/docs folder,
// - "laravel" will generate the documentation as a Blade view, so you can add routing and authentication.
// - "external_static" and "external_laravel" do the same as above, but pass the OpenAPI spec as a URL to an external UI template
'type' => 'laravel',
// See https://scribe.knuckles.wtf/laravel/reference/config#theme for supported options
'theme' => 'default',
'static' => [
// HTML documentation, assets and Postman collection will be generated to this folder.
// Source Markdown will still be in resources/docs.
'output_path' => 'public/docs',
],
'laravel' => [
// Whether to automatically create a docs route for you to view your generated docs. You can still set up routing manually.
'add_routes' => true,
// URL path to use for the docs endpoint (if `add_routes` is true).
// By default, `/docs` opens the HTML page, `/docs.postman` opens the Postman collection, and `/docs.openapi` the OpenAPI spec.
'docs_url' => '/docs',
// Directory within `public` in which to store CSS and JS assets.
// By default, assets are stored in `public/vendor/scribe`.
// If set, assets will be stored in `public/{{assets_directory}}`
'assets_directory' => null,
// Middleware to attach to the docs endpoint (if `add_routes` is true).
'middleware' => [],
],
'external' => [
'html_attributes' => [],
],
'try_it_out' => [
// Add a Try It Out button to your endpoints so consumers can test endpoints right from their browser.
// Don't forget to enable CORS headers for your endpoints.
'enabled' => true,
// The base URL to use in the API tester. Leave as null to be the same as the displayed URL (`scribe.base_url`).
'base_url' => null,
// [Laravel Sanctum] Fetch a CSRF token before each request, and add it as an X-XSRF-TOKEN header.
'use_csrf' => false,
// The URL to fetch the CSRF token from (if `use_csrf` is true).
'csrf_url' => '/sanctum/csrf-cookie',
],
// How is your API authenticated? This information will be used in the displayed docs, generated examples and response calls.
'auth' => [
// Set this to true if ANY endpoints in your API use authentication.
'enabled' => false,
// Set this to true if your API should be authenticated by default. If so, you must also set `enabled` (above) to true.
// You can then use @unauthenticated or @authenticated on individual endpoints to change their status from the default.
'default' => false,
// Where is the auth value meant to be sent in a request?
'in' => AuthIn::BEARER->value,
// The name of the auth parameter (e.g. token, key, apiKey) or header (e.g. Authorization, Api-Key).
'name' => 'key',
// The value of the parameter to be used by Scribe to authenticate response calls.
// This will NOT be included in the generated documentation. If empty, Scribe will use a random value.
'use_value' => env('SCRIBE_AUTH_KEY'),
// Placeholder your users will see for the auth parameter in the example requests.
// Set this to null if you want Scribe to use a random value as placeholder instead.
'placeholder' => '{YOUR_AUTH_KEY}',
// Any extra authentication-related info for your users. Markdown and HTML are supported.
'extra_info' => 'You can retrieve your token by visiting your dashboard and clicking <b>Generate API token</b>.',
],
// Example requests for each endpoint will be shown in each of these languages.
// Supported options are: bash, javascript, php, python
// To add a language of your own, see https://scribe.knuckles.wtf/laravel/advanced/example-requests
// Note: does not work for `external` docs types
'example_languages' => [
'bash',
'javascript',
],
// Generate a Postman collection (v2.1.0) in addition to HTML docs.
// For 'static' docs, the collection will be generated to public/docs/collection.json.
// For 'laravel' docs, it will be generated to storage/app/scribe/collection.json.
// Setting `laravel.add_routes` to true (above) will also add a route for the collection.
'postman' => [
'enabled' => true,
'overrides' => [
// 'info.version' => '2.0.0',
],
],
// Generate an OpenAPI spec in addition to docs webpage.
// For 'static' docs, the collection will be generated to public/docs/openapi.yaml.
// For 'laravel' docs, it will be generated to storage/app/scribe/openapi.yaml.
// Setting `laravel.add_routes` to true (above) will also add a route for the spec.
'openapi' => [
'enabled' => true,
// The OpenAPI spec version to generate. Supported versions: '3.0.3', '3.1.0'.
// OpenAPI 3.1 is more compatible with JSON Schema and is becoming the dominant version.
// See https://spec.openapis.org/oas/v3.1.0 for details on 3.1 changes.
'version' => '3.0.3',
'overrides' => [
// 'info.version' => '2.0.0',
],
// Additional generators to use when generating the OpenAPI spec.
// Should extend `Knuckles\Scribe\Writing\OpenApiSpecGenerators\OpenApiGenerator`.
'generators' => [],
],
'groups' => [
// Endpoints which don't have a @group will be placed in this default group.
'default' => 'Endpoints',
// By default, Scribe will sort groups alphabetically, and endpoints in the order their routes are defined.
// You can override this by listing the groups, subgroups and endpoints here in the order you want them.
// See https://scribe.knuckles.wtf/blog/laravel-v4#easier-sorting and https://scribe.knuckles.wtf/laravel/reference/config#order for details
// Note: does not work for `external` docs types
'order' => [],
],
// Custom logo path. This will be used as the value of the src attribute for the <img> tag,
// so make sure it points to an accessible URL or path. Set to false to not use a logo.
// For example, if your logo is in public/img:
// - 'logo' => '../img/logo.png' // for `static` type (output folder is public/docs)
// - 'logo' => 'img/logo.png' // for `laravel` type
'logo' => false,
// Customize the "Last updated" value displayed in the docs by specifying tokens and formats.
// Examples:
// - {date:F j Y} => March 28, 2022
// - {git:short} => Short hash of the last Git commit
// Available tokens are `{date:<format>}` and `{git:<format>}`.
// The format you pass to `date` will be passed to PHP's `date()` function.
// The format you pass to `git` can be either "short" or "long".
// Note: does not work for `external` docs types
'last_updated' => 'Last updated: {date:F j, Y}',
'examples' => [
// Set this to any number to generate the same example values for parameters on each run,
'faker_seed' => 1234,
// With API resources and transformers, Scribe tries to generate example models to use in your API responses.
// By default, Scribe will try the model's factory, and if that fails, try fetching the first from the database.
// You can reorder or remove strategies here.
'models_source' => ['factoryCreate', 'factoryMake', 'databaseFirst'],
],
// The strategies Scribe will use to extract information about your routes at each stage.
// Use configureStrategy() to specify settings for a strategy in the list.
// Use removeStrategies() to remove an included strategy.
'strategies' => [
'metadata' => [
...Defaults::METADATA_STRATEGIES,
],
'headers' => [
...Defaults::HEADERS_STRATEGIES,
Strategies\StaticData::withSettings(data: [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
]),
],
'urlParameters' => [
...Defaults::URL_PARAMETERS_STRATEGIES,
],
'queryParameters' => [
...Defaults::QUERY_PARAMETERS_STRATEGIES,
],
'bodyParameters' => [
...Defaults::BODY_PARAMETERS_STRATEGIES,
],
'responses' => configureStrategy(
Defaults::RESPONSES_STRATEGIES,
Strategies\Responses\ResponseCalls::withSettings(
only: ['GET *'],
// Recommended: disable debug mode in response calls to avoid error stack traces in responses
config: [
'app.debug' => false,
]
)
),
'responseFields' => [
...Defaults::RESPONSE_FIELDS_STRATEGIES,
],
],
// For response calls, API resource responses and transformer responses,
// Scribe will try to start database transactions, so no changes are persisted to your database.
// Tell Scribe which connections should be transacted here. If you only use one db connection, you can leave this as is.
'database_connections_to_transact' => [config('database.default')],
'fractal' => [
// If you are using a custom serializer with league/fractal, you can specify it here.
'serializer' => null,
],
];

View File

@@ -0,0 +1,77 @@
<?php
return [
'labels' => [
'search' => 'Search',
'base_url' => 'Base URL',
],
'auth' => [
'none' => 'This API is not authenticated.',
'instruction' => [
'query' => <<<'TEXT'
To authenticate requests, include a query parameter **`:parameterName`** in the request.
TEXT,
'body' => <<<'TEXT'
To authenticate requests, include a parameter **`:parameterName`** in the body of the request.
TEXT,
'query_or_body' => <<<'TEXT'
To authenticate requests, include a parameter **`:parameterName`** either in the query string or in the request body.
TEXT,
'bearer' => <<<'TEXT'
To authenticate requests, include an **`Authorization`** header with the value **`"Bearer :placeholder"`**.
TEXT,
'basic' => <<<'TEXT'
To authenticate requests, include an **`Authorization`** header in the form **`"Basic {credentials}"`**.
The value of `{credentials}` should be your username/id and your password, joined with a colon (:),
and then base64-encoded.
TEXT,
'header' => <<<'TEXT'
To authenticate requests, include a **`:parameterName`** header with the value **`":placeholder"`**.
TEXT,
],
'details' => <<<'TEXT'
All authenticated endpoints are marked with a `requires authentication` badge in the documentation below.
TEXT,
],
'headings' => [
'introduction' => 'Introduction',
'auth' => 'Authenticating requests',
],
'endpoint' => [
'request' => 'Request',
'headers' => 'Headers',
'url_parameters' => 'URL Parameters',
'body_parameters' => 'Body Parameters',
'query_parameters' => 'Query Parameters',
'response' => 'Response',
'response_fields' => 'Response Fields',
'example_request' => 'Example request',
'example_response' => 'Example response',
'responses' => [
'binary' => 'Binary data',
'empty' => 'Empty response',
],
],
'try_it_out' => [
'open' => 'Try it out ⚡',
'cancel' => 'Cancel 🛑',
'send' => 'Send Request 💥',
'loading' => '⏱ Sending...',
'received_response' => 'Received response',
'request_failed' => 'Request failed with error',
'error_help' => <<<'TEXT'
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
TEXT,
],
'links' => [
'postman' => 'View Postman collection',
'openapi' => 'View OpenAPI spec',
],
];

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -0,0 +1,393 @@
/* Copied from https://github.com/slatedocs/slate/blob/c4b4c0b8f83e891ca9fab6bbe9a1a88d5fe41292/stylesheets/print.css and unminified */
/*! normalize.css v3.0.2 | MIT License | git.io/normalize */
html {
font-family: sans-serif;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%
}
body {
margin: 0
}
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
main,
menu,
nav,
section,
summary {
display: block
}
audio,
canvas,
progress,
video {
display: inline-block;
vertical-align: baseline
}
audio:not([controls]) {
display: none;
height: 0
}
[hidden],
template {
display: none
}
a {
background-color: transparent
}
a:active,
a:hover {
outline: 0
}
abbr[title] {
border-bottom: 1px dotted
}
b,
strong {
font-weight: bold
}
dfn {
font-style: italic
}
h1 {
font-size: 2em;
margin: 0.67em 0
}
mark {
background: #ff0;
color: #000
}
small {
font-size: 80%
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline
}
sup {
top: -0.5em
}
sub {
bottom: -0.25em
}
img {
border: 0
}
svg:not(:root) {
overflow: hidden
}
figure {
margin: 1em 40px
}
hr {
box-sizing: content-box;
height: 0
}
pre {
overflow: auto
}
code,
kbd,
pre,
samp {
font-family: monospace, monospace;
font-size: 1em
}
button,
input,
optgroup,
select,
textarea {
color: inherit;
font: inherit;
margin: 0
}
button {
overflow: visible
}
button,
select {
text-transform: none
}
button,
html input[type="button"],
input[type="reset"],
input[type="submit"] {
-webkit-appearance: button;
cursor: pointer
}
button[disabled],
html input[disabled] {
cursor: default
}
button::-moz-focus-inner,
input::-moz-focus-inner {
border: 0;
padding: 0
}
input {
line-height: normal
}
input[type="checkbox"],
input[type="radio"] {
box-sizing: border-box;
padding: 0
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
height: auto
}
input[type="search"] {
-webkit-appearance: textfield;
box-sizing: content-box
}
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
-webkit-appearance: none
}
fieldset {
border: 1px solid #c0c0c0;
margin: 0 2px;
padding: 0.35em 0.625em 0.75em
}
legend {
border: 0;
padding: 0
}
textarea {
overflow: auto
}
optgroup {
font-weight: bold
}
table {
border-collapse: collapse;
border-spacing: 0
}
td,
th {
padding: 0
}
.content h1,
.content h2,
.content h3,
.content h4,
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 14px
}
.content h1,
.content h2,
.content h3,
.content h4 {
font-weight: bold
}
.content pre,
.content code {
font-family: Consolas, Menlo, Monaco, "Lucida Console", "Liberation Mono", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Courier New", monospace, serif;
font-size: 12px;
line-height: 1.5
}
.content pre,
.content code {
word-break: break-all;
-webkit-hyphens: auto;
-ms-hyphens: auto;
hyphens: auto
}
@font-face {
font-family: 'slate';
src: url(../fonts/slate.eot?-syv14m);
src: url(../fonts/slate.eot?#iefix-syv14m) format("embedded-opentype"), url(../fonts/slate.woff2?-syv14m) format("woff2"), url(../fonts/slate.woff?-syv14m) format("woff"), url(../fonts/slate.ttf?-syv14m) format("truetype"), url(../fonts/slate.svg?-syv14m#slate) format("svg");
font-weight: normal;
font-style: normal
}
.content aside.warning:before,
.content aside.notice:before,
.content aside.success:before {
font-family: 'slate';
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1
}
.content aside.warning:before {
content: "\e600"
}
.content aside.notice:before {
content: "\e602"
}
.content aside.success:before {
content: "\e606"
}
.tocify,
.toc-footer,
.lang-selector,
.search,
#nav-button {
display: none
}
.tocify-wrapper>img {
margin: 0 auto;
display: block
}
.content {
font-size: 12px
}
.content pre,
.content code {
border: 1px solid #999;
border-radius: 5px;
font-size: 0.8em
}
.content pre code {
border: 0
}
.content pre {
padding: 1.3em
}
.content code {
padding: 0.2em
}
.content table {
border: 1px solid #999
}
.content table tr {
border-bottom: 1px solid #999
}
.content table td,
.content table th {
padding: 0.7em
}
.content p {
line-height: 1.5
}
.content a {
text-decoration: none;
color: #000
}
.content h1 {
font-size: 2.5em;
padding-top: 0.5em;
padding-bottom: 0.5em;
margin-top: 1em;
margin-bottom: 21px;
border: 2px solid #ccc;
border-width: 2px 0;
text-align: center
}
.content h2 {
font-size: 1.8em;
margin-top: 2em;
border-top: 2px solid #ccc;
padding-top: 0.8em
}
.content h1+h2,
.content h1+div+h2 {
border-top: none;
padding-top: 0;
margin-top: 0
}
.content h3,
.content h4 {
font-size: 0.8em;
margin-top: 1.5em;
margin-bottom: 0.8em;
text-transform: uppercase
}
.content h5,
.content h6 {
text-transform: uppercase
}
.content aside {
padding: 1em;
border: 1px solid #ccc;
border-radius: 5px;
margin-top: 1.5em;
margin-bottom: 1.5em;
line-height: 1.6
}
.content aside:before {
vertical-align: middle;
padding-right: 0.5em;
font-size: 14px
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
# To include an endpoint that isn't a part of your Laravel app (or belongs to a vendor package),
# you can define it in a custom.*.yaml file, like this one.
# Each custom file should contain an array of endpoints. Here's an example:
# See https://scribe.knuckles.wtf/laravel/documenting/custom-endpoints#extra-sorting-groups-in-custom-endpoint-files for more options
#- httpMethods:
# - POST
# uri: api/doSomething/{param}
# metadata:
# groupName: The group the endpoint belongs to. Can be a new group or an existing group.
# groupDescription: A description for the group. You don't need to set this for every endpoint; once is enough.
# subgroup: You can add a subgroup, too.
# title: Do something
# description: 'This endpoint allows you to do something.'
# authenticated: false
# headers:
# Content-Type: application/json
# Accept: application/json
# urlParameters:
# param:
# name: param
# description: A URL param for no reason.
# required: true
# example: 2
# type: integer
# queryParameters:
# speed:
# name: speed
# description: How fast the thing should be done. Can be `slow` or `fast`.
# required: false
# example: fast
# type: string
# bodyParameters:
# something:
# name: something
# description: The things we should do.
# required: true
# example:
# - string 1
# - string 2
# type: 'string[]'
# responses:
# - status: 200
# description: 'When the thing was done smoothly.'
# content: # Your response content can be an object, an array, a string or empty.
# {
# "hey": "ho ho ho"
# }
# responseFields:
# hey:
# name: hey
# description: Who knows?
# type: string # This is optional

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

View File

@@ -0,0 +1,149 @@
document.addEventListener('DOMContentLoaded', function() {
const updateHash = function (id) {
window.location.hash = `#${id}`;
};
const navButton = document.getElementById('nav-button');
const menuWrapper = document.querySelector('.tocify-wrapper');
function toggleSidebar(event) {
event.preventDefault();
if (menuWrapper) {
menuWrapper.classList.toggle('open');
navButton.classList.toggle('open');
}
}
function closeSidebar() {
if (menuWrapper) {
menuWrapper.classList.remove('open');
navButton.classList.remove('open');
}
}
navButton.addEventListener('click', toggleSidebar);
window.hljs.highlightAll();
const wrapper = document.getElementById('toc');
// https://jets.js.org/
window.jets = new window.Jets({
// *OR - Selects elements whose values contains at least one part of search substring
searchSelector: '*OR',
searchTag: '#input-search',
contentTag: '#toc li',
didSearch: function(term) {
wrapper.classList.toggle('jets-searching', String(term).length > 0)
},
// map these accent keys to plain values
diacriticsMap: {
a: 'ÀÁÂÃÄÅàáâãäåĀāąĄ',
c: 'ÇçćĆčČ',
d: 'đĐďĎ',
e: 'ÈÉÊËèéêëěĚĒēęĘ',
i: 'ÌÍÎÏìíîïĪī',
l: 'łŁ',
n: 'ÑñňŇńŃ',
o: 'ÒÓÔÕÕÖØòóôõöøŌō',
r: 'řŘ',
s: 'ŠšśŚ',
t: 'ťŤ',
u: 'ÙÚÛÜùúûüůŮŪū',
y: 'ŸÿýÝ',
z: 'ŽžżŻźŹ'
}
});
function hashChange() {
const currentItems = document.querySelectorAll('.tocify-subheader.visible, .tocify-item.tocify-focus');
Array.from(currentItems).forEach((elem) => {
elem.classList.remove('visible', 'tocify-focus');
});
const currentTag = document.querySelector(`a[href="${window.location.hash}"]`);
if (currentTag) {
const parent = currentTag.closest('.tocify-subheader');
if (parent) {
parent.classList.add('visible');
}
const siblings = currentTag.closest('.tocify-header');
if (siblings) {
Array.from(siblings.querySelectorAll('.tocify-subheader')).forEach((elem) => {
elem.classList.add('visible');
});
}
currentTag.parentElement.classList.add('tocify-focus');
// wait for dom changes to be done
setTimeout(() => {
currentTag.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
// only close the sidebar on level-2 events
if (currentTag.parentElement.classList.contains('level-2')) {
closeSidebar();
}
}, 1500);
}
}
let languages = JSON.parse(document.body.getAttribute('data-languages'));
// Support a key => value object where the key is the name, or an array of strings where the value is the name
if (!Array.isArray(languages)) {
languages = Object.values(languages);
}
// if there is no language use the first one
const currentLanguage = window.localStorage.getItem('language') || languages[0];
const languageStyle = document.getElementById('language-style');
const langSelector = document.querySelectorAll('.lang-selector button.lang-button');
function setActiveLanguage(newLanguage) {
window.localStorage.setItem('language', newLanguage);
if (!languageStyle) {
return;
}
const newStyle = languages.map((language) => {
return language === newLanguage
// the current one should be visible
? `body .content .${language}-example pre { display: block; }`
// the inactive one should be hidden
: `body .content .${language}-example pre { display: none; }`;
}).join(`\n`);
Array.from(langSelector).forEach((elem) => {
elem.classList.toggle('active', elem.getAttribute('data-language-name') === newLanguage);
});
const activeHash = window.location.hash.slice(1);
languageStyle.innerHTML = newStyle;
setTimeout(() => {
updateHash(activeHash);
}, 200);
}
setActiveLanguage(currentLanguage);
Array.from(langSelector).forEach((elem) => {
elem.addEventListener('click', () => {
const newLanguage = elem.getAttribute('data-language-name');
setActiveLanguage(newLanguage);
});
});
window.addEventListener('hashchange', hashChange, false);
const divs = document.querySelectorAll('.content h1[id], .content h2[id]');
document.addEventListener('scroll', () => {
divs.forEach(item => {
const rect = item.getBoundingClientRect();
if (rect.top > 0 && rect.top < 150) {
const location = window.location.toString().split('#')[0];
history.replaceState(null, null, location + '#' + item.id);
hashChange();
}
});
});
hashChange();
});

View File

@@ -0,0 +1,289 @@
window.abortControllers = {};
function cacheAuthValue() {
// Whenever the auth header is set for one endpoint, cache it for the others
window.lastAuthValue = '';
let authInputs = document.querySelectorAll(`.auth-value`)
authInputs.forEach(el => {
el.addEventListener('input', (event) => {
window.lastAuthValue = event.target.value;
authInputs.forEach(otherInput => {
if (otherInput === el) return;
// Don't block the main thread
setTimeout(() => {
otherInput.value = window.lastAuthValue;
}, 0);
});
});
});
}
window.addEventListener('DOMContentLoaded', cacheAuthValue);
function getCookie(name) {
if (!document.cookie) {
return null;
}
const cookies = document.cookie.split(';')
.map(c => c.trim())
.filter(c => c.startsWith(name + '='));
if (cookies.length === 0) {
return null;
}
return decodeURIComponent(cookies[0].split('=')[1]);
}
function tryItOut(endpointId) {
document.querySelector(`#btn-tryout-${endpointId}`).hidden = true;
document.querySelector(`#btn-canceltryout-${endpointId}`).hidden = false;
const executeBtn = document.querySelector(`#btn-executetryout-${endpointId}`).hidden = false;
executeBtn.disabled = false;
// Show all input fields
document.querySelectorAll(`input[data-endpoint=${endpointId}],label[data-endpoint=${endpointId}]`)
.forEach(el => el.style.display = 'block');
if (document.querySelector(`#form-${endpointId}`).dataset.authed === "1") {
const authElement = document.querySelector(`#auth-${endpointId}`);
authElement && (authElement.hidden = false);
}
// Expand all nested fields
document.querySelectorAll(`#form-${endpointId} details`)
.forEach(el => el.open = true);
}
function cancelTryOut(endpointId) {
if (window.abortControllers[endpointId]) {
window.abortControllers[endpointId].abort();
delete window.abortControllers[endpointId];
}
document.querySelector(`#btn-tryout-${endpointId}`).hidden = false;
const executeBtn = document.querySelector(`#btn-executetryout-${endpointId}`);
executeBtn.hidden = true;
executeBtn.textContent = executeBtn.dataset.initialText;
document.querySelector(`#btn-canceltryout-${endpointId}`).hidden = true;
// Hide inputs
document.querySelectorAll(`input[data-endpoint=${endpointId}],label[data-endpoint=${endpointId}]`)
.forEach(el => el.style.display = 'none');
document.querySelectorAll(`#form-${endpointId} details`)
.forEach(el => el.open = false);
const authElement = document.querySelector(`#auth-${endpointId}`);
authElement && (authElement.hidden = true);
document.querySelector('#execution-results-' + endpointId).hidden = true;
document.querySelector('#execution-error-' + endpointId).hidden = true;
// Revert to sample code blocks
document.querySelector('#example-requests-' + endpointId).hidden = false;
document.querySelector('#example-responses-' + endpointId).hidden = false;
}
function makeAPICall(method, path, body = {}, query = {}, headers = {}, endpointId = null) {
console.log({endpointId, path, body, query, headers});
if (!(body instanceof FormData) && typeof body !== "string") {
body = JSON.stringify(body)
}
const url = new URL(window.tryItOutBaseUrl + '/' + path.replace(/^\//, ''));
// We need this function because if you try to set an array or object directly to a URLSearchParams object,
// you'll get [object Object] or the array.toString()
function addItemToSearchParamsObject(key, value, searchParams) {
if (Array.isArray(value)) {
value.forEach((v, i) => {
// Append {filters: [first, second]} as filters[0]=first&filters[1]second
addItemToSearchParamsObject(key + '[' + i + ']', v, searchParams);
})
} else if (typeof value === 'object' && value !== null) {
Object.keys(value).forEach((i) => {
// Append {filters: {name: first}} as filters[name]=first
addItemToSearchParamsObject(key + '[' + i + ']', value[i], searchParams);
});
} else {
searchParams.append(key, value);
}
}
Object.keys(query)
.forEach(key => addItemToSearchParamsObject(key, query[key], url.searchParams));
window.abortControllers[endpointId] = new AbortController();
return fetch(url, {
method,
headers,
body: method === 'GET' ? undefined : body,
signal: window.abortControllers[endpointId].signal,
referrer: window.tryItOutBaseUrl,
mode: 'cors',
credentials: 'same-origin',
})
.then(response => Promise.all([response.status, response.statusText, response.text(), response.headers]));
}
function hideCodeSamples(endpointId) {
document.querySelector('#example-requests-' + endpointId).hidden = true;
document.querySelector('#example-responses-' + endpointId).hidden = true;
}
function handleResponse(endpointId, response, status, headers) {
hideCodeSamples(endpointId);
// Hide error views
document.querySelector('#execution-error-' + endpointId).hidden = true;
const responseContentEl = document.querySelector('#execution-response-content-' + endpointId);
// Check if the response contains Laravel's dd() default dump output
const isLaravelDump = response.includes('Sfdump');
// If it's a Laravel dd() dump, use innerHTML to render it safely
if (isLaravelDump) {
responseContentEl.innerHTML = response === '' ? responseContentEl.dataset.emptyResponseText : response;
} else {
// Otherwise, stick to textContent for regular responses
responseContentEl.textContent = response === '' ? responseContentEl.dataset.emptyResponseText : response;
}
// Prettify it if it's JSON
let isJson = false;
try {
const jsonParsed = JSON.parse(response);
if (jsonParsed !== null) {
isJson = true;
response = JSON.stringify(jsonParsed, null, 4);
responseContentEl.textContent = response;
}
} catch (e) {
}
isJson && window.hljs.highlightElement(responseContentEl);
const statusEl = document.querySelector('#execution-response-status-' + endpointId);
statusEl.textContent = ` (${status})`;
document.querySelector('#execution-results-' + endpointId).hidden = false;
statusEl.scrollIntoView({behavior: "smooth", block: "center"});
}
function handleError(endpointId, err) {
hideCodeSamples(endpointId);
// Hide response views
document.querySelector('#execution-results-' + endpointId).hidden = true;
// Show error views
let errorMessage = err.message || err;
const $errorMessageEl = document.querySelector('#execution-error-message-' + endpointId);
$errorMessageEl.textContent = errorMessage + $errorMessageEl.textContent;
const errorEl = document.querySelector('#execution-error-' + endpointId);
errorEl.hidden = false;
errorEl.scrollIntoView({behavior: "smooth", block: "center"});
}
async function executeTryOut(endpointId, form) {
const executeBtn = document.querySelector(`#btn-executetryout-${endpointId}`);
executeBtn.textContent = executeBtn.dataset.loadingText;
executeBtn.disabled = true;
executeBtn.scrollIntoView({behavior: "smooth", block: "center"});
let body;
let setter;
if (form.dataset.hasfiles === "1") {
body = new FormData();
setter = (name, value) => body.append(name, value);
} else if (form.dataset.isarraybody === "1") {
body = [];
setter = (name, value) => _.set(body, name, value);
} else {
body = {};
setter = (name, value) => _.set(body, name, value);
}
const bodyParameters = form.querySelectorAll('input[data-component=body]');
bodyParameters.forEach(el => {
let value = el.value;
if (el.type === 'number' && typeof value === 'string') {
value = parseFloat(value);
}
if (el.type === 'file' && el.files[0]) {
setter(el.name, el.files[0]);
return;
}
if (el.type !== 'radio') {
if (value === "" && el.required === false) {
// Don't include empty optional values in the request
return;
}
setter(el.name, value);
return;
}
if (el.checked) {
value = (value === 'false') ? false : true;
setter(el.name, value);
}
});
const query = {};
const queryParameters = form.querySelectorAll('input[data-component=query]');
queryParameters.forEach(el => {
if (el.type !== 'radio' || (el.type === 'radio' && el.checked)) {
if (el.value === '') {
// Don't include empty values in the request
return;
}
_.set(query, el.name, el.value);
}
});
let path = form.dataset.path;
const urlParameters = form.querySelectorAll('input[data-component=url]');
urlParameters.forEach(el => (path = path.replace(new RegExp(`\\{${el.name}\\??}`), el.value)));
const headers = Object.fromEntries(Array.from(form.querySelectorAll('input[data-component=header]'))
.map(el => [el.name, el.value]));
// When using FormData, the browser sets the correct content-type + boundary
let method = form.dataset.method;
if (body instanceof FormData) {
delete headers['Content-Type'];
// When using FormData with PUT or PATCH, use method spoofing so PHP can access the post body
if (['PUT', 'PATCH'].includes(form.dataset.method)) {
method = 'POST';
setter('_method', form.dataset.method);
}
}
let preflightPromise = Promise.resolve();
if (window.useCsrf && window.csrfUrl) {
preflightPromise = makeAPICall('GET', window.csrfUrl).then(() => {
headers['X-XSRF-TOKEN'] = getCookie('XSRF-TOKEN');
});
}
return preflightPromise.then(() => makeAPICall(method, path, body, query, headers, endpointId))
.then(([responseStatus, statusText, responseContent, responseHeaders]) => {
handleResponse(endpointId, responseContent, responseStatus, responseHeaders)
})
.catch(err => {
if (err.name === "AbortError") {
console.log("Request cancelled");
return;
}
console.log("Error while making request: ", err);
handleError(endpointId, err);
})
.finally(() => {
executeBtn.disabled = false;
executeBtn.textContent = executeBtn.dataset.initialText;
});
}

View File

@@ -0,0 +1,3 @@
@if($authenticated)@component('scribe::components.badges.base', ['colour' => "darkred", 'text' => 'requires authentication'])
@endcomponent
@endif

View File

@@ -0,0 +1 @@
<small class="badge badge-{{ $colour }}">{{ $text }}</small>

View File

@@ -0,0 +1,5 @@
@if($deprecated !== false)
@php($text = $deprecated === true ? 'deprecated' : "deprecated:$deprecated")
@component('scribe::components.badges.base', ['colour' => 'darkgoldenrod', 'text' => $text])
@endcomponent
@endif

View File

@@ -0,0 +1,5 @@
@component('scribe::components.badges.base', [
'colour' => \Knuckles\Scribe\Tools\WritingUtils::$httpMethodToCssColour[$method],
'text' => $method,
])
@endcomponent

View File

@@ -0,0 +1,76 @@
@php
$html ??= []; $class = $html['class'] ?? null;
@endphp
<b style="line-height: 2;"><code>{{ $name }}</code></b>&nbsp;&nbsp;
@if($type)<small>{{ $type }}</small>@endif&nbsp;
@if($isInput && !$required)<i>optional</i>@endif &nbsp;
@if($isInput && $deprecated)<i>deprecated</i>@endif &nbsp;
@if($isInput && empty($hasChildren))
@php
$isList = Str::endsWith($type, '[]');
$fullName = str_replace('[]', '.0', $fullName ?? $name);
$baseType = $isList ? substr($type, 0, -2) : $type;
// Ignore the first '[]': the frontend will take care of it
while (\Str::endsWith($baseType, '[]')) {
$fullName .= '.0';
$baseType = substr($baseType, 0, -2);
}
// When the body is an array, the item names will be ".0.thing"
$fullName = ltrim($fullName, '.');
$inputType = match($baseType) {
'number', 'integer' => 'number',
'file' => 'file',
default => 'text',
};
@endphp
@if($type === 'boolean')
<label data-endpoint="{{ $endpointId }}" style="display: none">
<input type="radio" name="{{ $fullName }}"
value="{{$component === 'body' ? 'true' : 1}}"
data-endpoint="{{ $endpointId }}"
data-component="{{ $component }}" @if($class)class="{{ $class }}"@endif
>
<code>true</code>
</label>
<label data-endpoint="{{ $endpointId }}" style="display: none">
<input type="radio" name="{{ $fullName }}"
value="{{$component === 'body' ? 'false' : 0}}"
data-endpoint="{{ $endpointId }}"
data-component="{{ $component }}" @if($class)class="{{ $class }}"@endif
>
<code>false</code>
</label>
@elseif($isList)
<input type="{{ $inputType }}" style="display: none"
@if($inputType === 'number')step="any"@endif
name="{{ $fullName."[0]" }}" @if($class)class="{{ $class }}"@endif
data-endpoint="{{ $endpointId }}"
data-component="{{ $component }}">
<input type="{{ $inputType }}" style="display: none"
name="{{ $fullName."[1]" }}" @if($class)class="{{ $class }}"@endif
data-endpoint="{{ $endpointId }}"
data-component="{{ $component }}">
@else
<input type="{{ $inputType }}" style="display: none"
@if($inputType === 'number')step="any"@endif
name="{{ $fullName }}" @if($class)class="{{ $class }}"@endif
data-endpoint="{{ $endpointId }}"
value="{!! (isset($example) && (is_string($example) || is_numeric($example))) ? $example : '' !!}"
data-component="{{ $component }}">
@endif
@endif
<br>
@php
if($example !== null && $example !== '' && !is_array($example)) {
$exampleAsString = $example;
if (is_bool($example)) {
$exampleAsString = $example ? "true" : "false";
}
$description .= " Example: `$exampleAsString`";
}
@endphp
{!! Parsedown::instance()->text(trim($description)) !!}
@if(!empty($enumValues))
Must be one of:
<ul style="list-style-type: square;">{!! implode(" ", array_map(fn($val) => "<li><code>$val</code></li>", $enumValues)) !!}</ul>
@endif

View File

@@ -0,0 +1,105 @@
@php
$isInput ??= true;
$level ??= 0;
@endphp
@foreach($fields as $name => $field)
@if($name === '[]')
@php
$description = "The request body is an array (<code>{$field['type']}</code>`)";
$description .= !empty($field['description']) ? ", representing ".lcfirst($field['description'])."." : '.';
if(count($field['__fields'])) $description .= " Each item has the following properties:";
@endphp
{!! Parsedown::instance()->text($description) !!}
@foreach($field['__fields'] as $subfieldName => $subfield)
@if(!empty($subfield['__fields']))
<x-scribe::nested-fields
:fields="[$subfieldName => $subfield]" :endpointId="$endpointId" :isInput="$isInput" :level="$level + 2"
/>
@else
<div style="margin-left: {{ ($level + 2) * 14 }}px; clear: unset;">
@component('scribe::components.field-details', [
'name' => $subfieldName,
'fullName' => $subfield['name'],
'type' => $subfield['type'] ?? 'string',
'required' => $subfield['required'] ?? false,
'deprecated' => $subfield['deprecated'] ?? false,
'description' => $subfield['description'] ?? '',
'example' => $subfield['example'] ?? '',
'enumValues' => $subfield['enumValues'] ?? null,
'endpointId' => $endpointId,
'hasChildren' => false,
'component' => 'body',
'isInput' => $isInput,
])
@endcomponent
</div>
@endif
@endforeach
@elseif(!empty($field['__fields']))
<div style="@if($level) margin-left: {{ $level * 14 }}px;@else padding-left: 28px; @endif clear: unset;">
<details>
<summary style="padding-bottom: 10px;">
@component('scribe::components.field-details', [
'name' => $name,
'fullName' => $field['name'],
'type' => $field['type'] ?? 'string',
'required' => $field['required'] ?? false,
'deprecated' => $field['deprecated'] ?? false,
'description' => $field['description'] ?? '',
'example' => $field['example'] ?? '',
'enumValues' => $field['enumValues'] ?? null,
'endpointId' => $endpointId,
'hasChildren' => true,
'component' => 'body',
'isInput' => $isInput,
])
@endcomponent
</summary>
@foreach($field['__fields'] as $subfieldName => $subfield)
@if(!empty($subfield['__fields']))
<x-scribe::nested-fields
:fields="[$subfieldName => $subfield]" :endpointId="$endpointId" :isInput="$isInput" :level="$level + 1"
/>
@else
<div style="margin-left: {{ ($level + 1) * 14 }}px; clear: unset;">
@component('scribe::components.field-details', [
'name' => $subfieldName,
'fullName' => $subfield['name'],
'type' => $subfield['type'] ?? 'string',
'required' => $subfield['required'] ?? false,
'deprecated' => $subfield['deprecated'] ?? false,
'description' => $subfield['description'] ?? '',
'example' => $subfield['example'] ?? '',
'enumValues' => $subfield['enumValues'] ?? null,
'endpointId' => $endpointId,
'hasChildren' => false,
'component' => 'body',
'isInput' => $isInput,
])
@endcomponent
</div>
@endif
@endforeach
</details>
</div>
@else
<div style="@if($level) margin-left: {{ ($level + 1) * 14 }}px;@else padding-left: 28px; @endif clear: unset;">
@component('scribe::components.field-details', [
'name' => $name,
'fullName' => $field['name'],
'type' => $field['type'] ?? 'string',
'required' => $field['required'] ?? false,
'deprecated' => $field['deprecated'] ?? false,
'description' => $field['description'] ?? '',
'example' => $field['example'] ?? '',
'enumValues' => $field['enumValues'] ?? null,
'endpointId' => $endpointId,
'hasChildren' => false,
'component' => 'body',
'isInput' => $isInput,
])
@endcomponent
</div>
@endif
@endforeach

View File

@@ -0,0 +1,34 @@
<!-- See https://github.com/stoplightio/elements/blob/main/docs/getting-started/elements/elements-options.md for config -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{!! $metadata['title'] !!}</title>
<!-- Embed elements Elements via Web Component -->
<script src="https://unpkg.com/@stoplight/elements/web-components.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@stoplight/elements/styles.min.css">
<style>
body {
height: 100vh;
}
</style>
</head>
<body>
<elements-api
@foreach($htmlAttributes as $attribute => $value)
{{-- Attributes specified first override later ones --}}
{!! $attribute !!}="{!! $value !!}"
@endforeach
apiDescriptionUrl="{!! $metadata['openapi_spec_url'] !!}"
router="hash"
layout="sidebar"
hideTryIt="{!! ($tryItOut['enabled'] ?? true) ? '' : 'true'!!}"
@if(!empty($metadata['logo']))
logo="{!! $metadata['logo'] !!}"
@endif
/>
</body>
</html>

View File

@@ -0,0 +1,23 @@
<!-- See https://rapidocweb.com/api.html for options -->
<!doctype html> <!-- Important: must specify -->
<html>
<head>
<meta charset="utf-8"> <!-- Important: rapi-doc uses utf8 characters -->
<script type="module" src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"></script>
</head>
<body>
<rapi-doc
@foreach($htmlAttributes as $attribute => $value)
{{-- Attributes specified first override later ones --}}
{!! $attribute !!}="{!! $value !!}"
@endforeach
spec-url="{!! $metadata['openapi_spec_url'] !!}"
render-style="read"
allow-try="{!! ($tryItOut['enabled'] ?? true) ? 'true' : 'false'!!}"
>
@if($metadata['logo'])
<img slot="logo" src="{!! $metadata['logo'] !!}"/>
@endif
</rapi-doc>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<!doctype html>
<html>
<head>
<title>{!! $metadata['title'] !!}</title>
<meta charset="utf-8"/>
<meta
name="viewport"
content="width=device-width, initial-scale=1"/>
<style>
body {
margin: 0;
}
</style>
</head>
<body>
<script
id="api-reference"
@foreach($htmlAttributes as $attribute => $value)
{{-- Attributes specified first override later ones --}}
{!! $attribute !!}="{!! $value !!}"
@endforeach
data-url="{!! $metadata['openapi_spec_url'] !!}">
</script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
@php
use Knuckles\Scribe\Tools\Utils as u;
@endphp
# {{ u::trans("scribe::headings.auth") }}
@if(!$isAuthed)
{!! u::trans("scribe::auth.none") !!}
@else
{!! $authDescription !!}
{!! $extraAuthInfo !!}
@endif

View File

@@ -0,0 +1,13 @@
@php
use Knuckles\Scribe\Tools\Utils as u;
@endphp
# {{ u::trans("scribe::headings.introduction") }}
{!! $description !!}
<aside>
<strong>{{ u::trans("scribe::labels.base_url") }}</strong>: <code>{!! $baseUrl !!}</code>
</aside>
{!! $introText !!}

View File

@@ -0,0 +1,34 @@
@php
use Knuckles\Scribe\Tools\WritingUtils as u;
/** @var Knuckles\Camel\Output\OutputEndpointData $endpoint */
@endphp
```bash
curl --request {{$endpoint->httpMethods[0]}} \
{{$endpoint->httpMethods[0] == 'GET' ? '--get ' : ''}}"{!! rtrim($baseUrl, '/') !!}/{{ ltrim($endpoint->boundUri, '/') }}@if(count($endpoint->cleanQueryParameters))?{!! u::printQueryParamsAsString($endpoint->cleanQueryParameters) !!}@endif"@if(count($endpoint->headers)) \
@foreach($endpoint->headers as $header => $value)
--header "{{$header}}: {{ addslashes($value) }}"@if(! ($loop->last) || ($loop->last && count($endpoint->bodyParameters))) \
@endif
@endforeach
@endif
@if($endpoint->hasFiles() || (isset($endpoint->headers['Content-Type']) && $endpoint->headers['Content-Type'] == 'multipart/form-data' && count($endpoint->cleanBodyParameters)))
@foreach($endpoint->cleanBodyParameters as $parameter => $value)
@foreach(u::getParameterNamesAndValuesForFormData($parameter, $value) as $key => $actualValue)
--form "{!! "$key=".$actualValue !!}"@if(!($loop->parent->last) || count($endpoint->fileParameters))\
@endif
@endforeach
@endforeach
@foreach($endpoint->fileParameters as $parameter => $value)
@foreach(u::getParameterNamesAndValuesForFormData($parameter, $value) as $key => $file)
--form "{!! "$key=@".$file->path() !!}" @if(!($loop->parent->last))\
@endif
@endforeach
@endforeach
@elseif(count($endpoint->cleanBodyParameters))
@if ($endpoint->headers['Content-Type'] == 'application/x-www-form-urlencoded')
--data "{!! http_build_query($endpoint->cleanBodyParameters, '', '&') !!}"
@else
--data "{!! addslashes(json_encode($endpoint->cleanBodyParameters, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) !!}"
@endif
@endif
```

View File

@@ -0,0 +1,62 @@
@php
use Knuckles\Scribe\Tools\WritingUtils as u;
/** @var Knuckles\Camel\Output\OutputEndpointData $endpoint */
@endphp
```javascript
const url = new URL(
"{!! rtrim($baseUrl, '/') !!}/{{ ltrim($endpoint->boundUri, '/') }}"
);
@if(count($endpoint->cleanQueryParameters))
const params = {!! u::printQueryParamsAsKeyValue($endpoint->cleanQueryParameters, "\"", ":", 4, "{}") !!};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
@endif
@if(!empty($endpoint->headers))
const headers = {
@foreach($endpoint->headers as $header => $value)
"{{$header}}": "{{$value}}",
@endforeach
@empty($endpoint->headers['Accept'])
"Accept": "application/json",
@endempty
};
@endif
@if($endpoint->hasFiles() || (isset($endpoint->headers['Content-Type']) && $endpoint->headers['Content-Type'] == 'multipart/form-data' && count($endpoint->cleanBodyParameters)))
const body = new FormData();
@foreach($endpoint->cleanBodyParameters as $parameter => $value)
@foreach( u::getParameterNamesAndValuesForFormData($parameter, $value) as $key => $actualValue)
body.append('{!! $key !!}', '{!! $actualValue !!}');
@endforeach
@endforeach
@foreach($endpoint->fileParameters as $parameter => $value)
@foreach( u::getParameterNamesAndValuesForFormData($parameter, $value) as $key => $file)
body.append('{!! $key !!}', document.querySelector('input[name="{!! $key !!}"]').files[0]);
@endforeach
@endforeach
@elseif(count($endpoint->cleanBodyParameters))
@if ($endpoint->headers['Content-Type'] == 'application/x-www-form-urlencoded')
let body = "{!! http_build_query($endpoint->cleanBodyParameters, '', '&') !!}";
@else
let body = {!! json_encode($endpoint->cleanBodyParameters, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) !!};
@endif
@endif
fetch(url, {
method: "{{$endpoint->httpMethods[0]}}",
@if(count($endpoint->headers))
headers,
@endif
@if($endpoint->hasFiles() || (isset($endpoint->headers['Content-Type']) && $endpoint->headers['Content-Type'] == 'multipart/form-data' && count($endpoint->cleanBodyParameters)))
body,
@elseif(count($endpoint->cleanBodyParameters))
@if ($endpoint->headers['Content-Type'] == 'application/x-www-form-urlencoded')
body,
@else
body: JSON.stringify(body),
@endif
@endif
}).then(response => response.json());
```

View File

@@ -0,0 +1,51 @@
@php
use Knuckles\Scribe\Tools\WritingUtils as u;
/** @var Knuckles\Camel\Output\OutputEndpointData $endpoint */
@endphp
```php
$client = new \GuzzleHttp\Client();
$url = '{!! rtrim($baseUrl, '/') . '/' . ltrim($endpoint->boundUri, '/') !!}';
@if($endpoint->hasHeadersOrQueryOrBodyParams())
$response = $client->{{ strtolower($endpoint->httpMethods[0]) }}(
$url,
[
@if(!empty($endpoint->headers))
'headers' => {!! u::printPhpValue($endpoint->headers, 8) !!},
@endif
@if(!empty($endpoint->cleanQueryParameters))
'query' => {!! u::printQueryParamsAsKeyValue($endpoint->cleanQueryParameters, "'", " =>", 12, "[]", 8) !!},
@endif
@if($endpoint->hasFiles() || (isset($endpoint->headers['Content-Type']) && $endpoint->headers['Content-Type'] == 'multipart/form-data' && !empty($endpoint->cleanBodyParameters)))
'multipart' => [
@foreach($endpoint->cleanBodyParameters as $parameter => $value)
@foreach(u::getParameterNamesAndValuesForFormData($parameter, $value) as $key => $actualValue)
[
'name' => '{!! $key !!}',
'contents' => '{!! $actualValue !!}'
],
@endforeach
@endforeach
@foreach($endpoint->fileParameters as $parameter => $value)
@foreach(u::getParameterNamesAndValuesForFormData($parameter, $value) as $key => $file)
[
'name' => '{!! $key !!}',
'contents' => fopen('{!! $file->path() !!}', 'r')
],
@endforeach
@endforeach
],
@elseif(count($endpoint->cleanBodyParameters))
@if ($endpoint->headers['Content-Type'] == 'application/x-www-form-urlencoded')
'form_params' => {!! u::printPhpValue($endpoint->cleanBodyParameters, 8) !!},
@else
'json' => {!! u::printPhpValue($endpoint->cleanBodyParameters, 8) !!},
@endif
@endif
]
);
@else
$response = $client->{{ strtolower($endpoint->httpMethods[0]) }}($url);
@endif
$body = $response->getBody();
print_r(json_decode((string) $body));
```

View File

@@ -0,0 +1,52 @@
@php
use Knuckles\Scribe\Tools\WritingUtils as u;
/** @var Knuckles\Camel\Output\OutputEndpointData $endpoint */
@endphp
```python
import requests
import json
url = '{!! rtrim($baseUrl, '/') !!}/{{ $endpoint->boundUri }}'
@if($endpoint->hasFiles() || (isset($endpoint->headers['Content-Type']) && $endpoint->headers['Content-Type'] == 'multipart/form-data' && count($endpoint->cleanBodyParameters)))
files = {
@foreach($endpoint->cleanBodyParameters as $parameter => $value)
@foreach(u::getParameterNamesAndValuesForFormData($parameter, $value) as $key => $actualValue)
'{!! $key !!}': (None, '{!! $actualValue !!}')@if(!($loop->parent->last) || count($endpoint->fileParameters)),
@endif
@endforeach
@endforeach
@foreach($endpoint->fileParameters as $parameter => $value)
@foreach(u::getParameterNamesAndValuesForFormData($parameter, $value) as $key => $file)
'{!! $key !!}': open('{!! $file->path() !!}', 'rb')@if(!($loop->parent->last)),
@endif
@endforeach
@endforeach
}
@endif
@if(count($endpoint->cleanBodyParameters))
payload = {!! json_encode($endpoint->cleanBodyParameters, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) !!}
@endif
@if(count($endpoint->cleanQueryParameters))
params = {!! u::printQueryParamsAsKeyValue($endpoint->cleanQueryParameters, "'", ":", 2, "{}") !!}
@endif
@if(!empty($endpoint->headers))
headers = {
@foreach($endpoint->headers as $header => $value)
'{{$header}}': '{{$value}}'@if(!($loop->last)),
@endif
@endforeach
}
@endif
@php
$optionalArguments = [];
if (count($endpoint->headers)) $optionalArguments[] = "headers=headers";
if (count($endpoint->fileParameters)) $optionalArguments[] = "files=files";
if (count($endpoint->cleanBodyParameters) && $endpoint->headers['Content-Type'] != 'multipart/form-data') $optionalArguments[] = (count($endpoint->fileParameters) || $endpoint->headers['Content-Type'] == 'application/x-www-form-urlencoded' ? "data=payload" : "json=payload");
if (count($endpoint->cleanQueryParameters)) $optionalArguments[] = "params=params";
$optionalArguments = implode(', ',$optionalArguments);
@endphp
response = requests.request('{{$endpoint->httpMethods[0]}}', url, {{ $optionalArguments }})
response.json()
```

View File

@@ -0,0 +1,190 @@
@php
use Knuckles\Scribe\Tools\Utils as u;
/** @var Knuckles\Camel\Output\OutputEndpointData $endpoint */
@endphp
<h2 id="{!! $endpoint->fullSlug() !!}">{{ $endpoint->name() }}</h2>
<p>
@component('scribe::components.badges.auth', ['authenticated' => $endpoint->isAuthed()])
@endcomponent
@component('scribe::components.badges.deprecated', ['deprecated' => $endpoint->metadata->deprecated])
@endcomponent
</p>
{!! Parsedown::instance()->text($endpoint->metadata->description ?: '') !!}
<span id="example-requests-{!! $endpoint->endpointId() !!}">
<blockquote>{{ u::trans("scribe::endpoint.example_request") }}:</blockquote>
@foreach($metadata['example_languages'] as $language)
<div class="{{ $language }}-example">
@include("scribe::partials.example-requests.$language")
</div>
@endforeach
</span>
<span id="example-responses-{!! $endpoint->endpointId() !!}">
@if($endpoint->isGet() || $endpoint->hasResponses())
@foreach($endpoint->responses as $response)
<blockquote>
<p>{{ u::trans("scribe::endpoint.example_response") }} ({{ $response->fullDescription() }}):</p>
</blockquote>
@if(count($response->headers))
<details class="annotation">
<summary style="cursor: pointer;">
<small onclick="textContent = parentElement.parentElement.open ? 'Show headers' : 'Hide headers'">Show headers</small>
</summary>
<pre><code class="language-http">@foreach($response->headers as $header => $value)
{{ $header }}: {{ is_array($value) ? implode('; ', $value) : $value }}
@endforeach </code></pre></details> @endif
<pre>
@if($response->isBinary())
<code>{!! u::trans("scribe::endpoint.responses.binary") !!} - {{ htmlentities(str_replace("<<binary>>", "", $response->content)) }}</code>
@elseif($response->status == 204)
<code>{!! u::trans("scribe::endpoint.responses.empty") !!}</code>
@else
@php($parsed = json_decode($response->content))
{{-- If response is a JSON string, prettify it. Otherwise, just print it --}}
<code class="language-json" style="max-height: 300px;">{!! htmlentities($parsed != null ? json_encode($parsed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $response->content) !!}</code>
@endif </pre>
@endforeach
@endif
</span>
<span id="execution-results-{{ $endpoint->endpointId() }}" hidden>
<blockquote>{{ u::trans("scribe::try_it_out.received_response") }}<span
id="execution-response-status-{{ $endpoint->endpointId() }}"></span>:
</blockquote>
<pre class="json"><code id="execution-response-content-{{ $endpoint->endpointId() }}"
data-empty-response-text="<{{ u::trans("scribe::endpoint.responses.empty") }}>" style="max-height: 400px;"></code></pre>
</span>
<span id="execution-error-{{ $endpoint->endpointId() }}" hidden>
<blockquote>{{ u::trans("scribe::try_it_out.request_failed") }}:</blockquote>
<pre><code id="execution-error-message-{{ $endpoint->endpointId() }}">{{ "\n\n".u::trans("scribe::try_it_out.error_help") }}</code></pre>
</span>
<form id="form-{{ $endpoint->endpointId() }}" data-method="{{ $endpoint->httpMethods[0] }}"
data-path="{{ $endpoint->uri }}"
data-authed="{{ $endpoint->isAuthed() ? 1 : 0 }}"
data-hasfiles="{{ $endpoint->hasFiles() ? 1 : 0 }}"
data-isarraybody="{{ $endpoint->isArrayBody() ? 1 : 0 }}"
autocomplete="off"
onsubmit="event.preventDefault(); executeTryOut('{{ $endpoint->endpointId() }}', this);">
<h3>
{{ u::trans("scribe::endpoint.request") }}&nbsp;&nbsp;&nbsp;
@if($metadata['try_it_out']['enabled'] ?? false)
<button type="button"
style="background-color: #8fbcd4; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-tryout-{{ $endpoint->endpointId() }}"
onclick="tryItOut('{{ $endpoint->endpointId() }}');">{{ u::trans("scribe::try_it_out.open") }}
</button>
<button type="button"
style="background-color: #c97a7e; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-canceltryout-{{ $endpoint->endpointId() }}"
onclick="cancelTryOut('{{ $endpoint->endpointId() }}');" hidden>{{ u::trans("scribe::try_it_out.cancel") }}
</button>&nbsp;&nbsp;
<button type="submit"
style="background-color: #6ac174; padding: 5px 10px; border-radius: 5px; border-width: thin;"
id="btn-executetryout-{{ $endpoint->endpointId() }}"
data-initial-text="{{ u::trans("scribe::try_it_out.send") }}"
data-loading-text="{{ u::trans("scribe::try_it_out.loading") }}"
hidden>{{ u::trans("scribe::try_it_out.send") }}
</button>
@endif
</h3>
@foreach($endpoint->httpMethods as $method)
<p>
@component('scribe::components.badges.http-method', ['method' => $method])@endcomponent
<b><code>{{$endpoint->uri}}</code></b>
</p>
@endforeach
@if(count($endpoint->headers))
<h4 class="fancy-heading-panel"><b>{{ u::trans("scribe::endpoint.headers") }}</b></h4>
@foreach($endpoint->headers as $name => $example)
<?php
$htmlOptions = [];
if ($endpoint->isAuthed() && 'header' == $metadata['auth']['location'] && $metadata['auth']['name'] == $name) {
$htmlOptions = ['class' => 'auth-value'];
}
?>
<div style="padding-left: 28px; clear: unset;">
@component('scribe::components.field-details', [
'name' => $name,
'type' => null,
'required' => true,
'deprecated' => false,
'description' => null,
'example' => $example,
'endpointId' => $endpoint->endpointId(),
'component' => 'header',
'isInput' => true,
'html' => $htmlOptions,
])
@endcomponent
</div>
@endforeach
@endif
@if(count($endpoint->urlParameters))
<h4 class="fancy-heading-panel"><b>{{ u::trans("scribe::endpoint.url_parameters") }}</b></h4>
@foreach($endpoint->urlParameters as $attribute => $parameter)
<div style="padding-left: 28px; clear: unset;">
@component('scribe::components.field-details', [
'name' => $parameter->name,
'type' => $parameter->type ?? 'string',
'required' => $parameter->required,
'deprecated' => $parameter->deprecated,
'description' => $parameter->description,
'example' => $parameter->example ?? '',
'enumValues' => $parameter->enumValues,
'endpointId' => $endpoint->endpointId(),
'component' => 'url',
'isInput' => true,
])
@endcomponent
</div>
@endforeach
@endif
@if(count($endpoint->queryParameters))
<h4 class="fancy-heading-panel"><b>{{ u::trans("scribe::endpoint.query_parameters") }}</b></h4>
@foreach($endpoint->queryParameters as $attribute => $parameter)
<?php
$htmlOptions = [];
if ($endpoint->isAuthed() && 'query' == $metadata['auth']['location'] && $metadata['auth']['name'] == $attribute) {
$htmlOptions = ['class' => 'auth-value'];
}
?>
<div style="padding-left: 28px; clear: unset;">
@component('scribe::components.field-details', [
'name' => $parameter->name,
'type' => $parameter->type,
'required' => $parameter->required,
'deprecated' => $parameter->deprecated,
'description' => $parameter->description,
'example' => $parameter->example ?? '',
'enumValues' => $parameter->enumValues,
'endpointId' => $endpoint->endpointId(),
'component' => 'query',
'isInput' => true,
'html' => $htmlOptions,
])
@endcomponent
</div>
@endforeach
@endif
@if(count($endpoint->nestedBodyParameters))
<h4 class="fancy-heading-panel"><b>{{ u::trans("scribe::endpoint.body_parameters") }}</b></h4>
<x-scribe::nested-fields
:fields="$endpoint->nestedBodyParameters" :endpointId="$endpoint->endpointId()"
/>
@endif
</form>
@if(count($endpoint->responseFields))
<h3>{{ u::trans("scribe::endpoint.response") }}</h3>
<h4 class="fancy-heading-panel"><b>{{ u::trans("scribe::endpoint.response_fields") }}</b></h4>
<x-scribe::nested-fields
:fields="$endpoint->nestedResponseFields" :endpointId="$endpoint->endpointId()"
:isInput="false"
/>
@endif

View File

@@ -0,0 +1,21 @@
@foreach($groupedEndpoints as $group)
<h1 id="{!! Str::slug($group['name']) !!}">{!! $group['name'] !!}</h1>
{!! Parsedown::instance()->text($group['description']) !!}
@foreach($group['subgroups'] as $subgroupName => $subgroup)
@if($subgroupName !== "")
<h2 id="{!! Str::slug($group['name']) !!}-{!! Str::slug($subgroupName) !!}">{{ $subgroupName }}</h2>
@php($subgroupDescription = collect($subgroup)->first(fn ($e) => $e->metadata->subgroupDescription)?->metadata?->subgroupDescription)
@if($subgroupDescription)
<p>
{!! Parsedown::instance()->text($subgroupDescription) !!}
</p>
@endif
@endif
@foreach($subgroup as $endpoint)
@include("scribe::themes.default.endpoint")
@endforeach
@endforeach
@endforeach

View File

@@ -0,0 +1,74 @@
@php
use Knuckles\Scribe\Tools\WritingUtils as u;
@endphp
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{!! $metadata['title'] !!}</title>
<link href="https://fonts.googleapis.com/css?family=Open+Sans&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{!! $assetPathPrefix !!}css/theme-default.style.css" media="screen">
<link rel="stylesheet" href="{!! $assetPathPrefix !!}css/theme-default.print.css" media="print">
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js"></script>
<link rel="stylesheet"
href="https://unpkg.com/@highlightjs/cdn-assets@11.6.0/styles/obsidian.min.css">
<script src="https://unpkg.com/@highlightjs/cdn-assets@11.6.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jets/0.14.1/jets.min.js"></script>
@if(isset($metadata['example_languages']))
<style id="language-style">
/* starts out as display none and is replaced with js later */
@foreach($metadata['example_languages'] as $lang)
body .content .{{ $lang }}-example code { display: none; }
@endforeach
</style>
@endif
@if($tryItOut['enabled'] ?? true)
<script>
var tryItOutBaseUrl = "{!! $tryItOut['base_url'] ?? $baseUrl !!}";
var useCsrf = Boolean({!! $tryItOut['use_csrf'] ?? null !!});
var csrfUrl = "{!! $tryItOut['csrf_url'] ?? null !!}";
</script>
<script src="{{ u::getVersionedAsset($assetPathPrefix.'js/tryitout.js') }}"></script>
@endif
<script src="{{ u::getVersionedAsset($assetPathPrefix.'js/theme-default.js') }}"></script>
</head>
<body data-languages="{{ json_encode($metadata['example_languages'] ?? []) }}">
@include("scribe::themes.default.sidebar")
<div class="page-wrapper">
<div class="dark-box"></div>
<div class="content">
{!! $intro !!}
{!! $auth !!}
@include("scribe::themes.default.groups")
{!! $append !!}
</div>
<div class="dark-box">
@if(isset($metadata['example_languages']))
<div class="lang-selector">
@foreach($metadata['example_languages'] as $name => $lang)
@php if (is_numeric($name)) $name = $lang; @endphp
<button type="button" class="lang-button" data-language-name="{{$lang}}">{{$name}}</button>
@endforeach
</div>
@endif
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,69 @@
@php
use Knuckles\Scribe\Tools\Utils as u;
@endphp
<a href="#" id="nav-button">
<span>
MENU
<img src="{!! $assetPathPrefix !!}images/navbar.png" alt="navbar-image"/>
</span>
</a>
<div class="tocify-wrapper">
@if($metadata['logo'] != false)
<img src="{{ $metadata['logo'] }}" alt="logo" class="logo" style="padding-top: 10px;" width="100%"/>
@endif
@isset($metadata['example_languages'])
<div class="lang-selector">
@foreach($metadata['example_languages'] as $name => $lang)
@php if (is_numeric($name)) $name = $lang; @endphp
<button type="button" class="lang-button" data-language-name="{{ $lang }}">{{ $name }}</button>
@endforeach
</div>
@endisset
<div class="search">
<input type="text" class="search" id="input-search" placeholder="{{ u::trans("scribe::labels.search") }}">
</div>
<div id="toc">
@foreach($headings as $h1)
<ul id="tocify-header-{{ $h1['slug'] }}" class="tocify-header">
<li class="tocify-item level-1" data-unique="{!! $h1['slug'] !!}">
<a href="#{!! $h1['slug'] !!}">{!! $h1['name'] !!}</a>
</li>
@if(count($h1['subheadings']) > 0)
<ul id="tocify-subheader-{!! $h1['slug'] !!}" class="tocify-subheader">
@foreach($h1['subheadings'] as $h2)
<li class="tocify-item level-2" data-unique="{!! $h2['slug'] !!}">
<a href="#{!! $h2['slug'] !!}">{!! $h2['name'] !!}</a>
</li>
@if(count($h2['subheadings']) > 0)
<ul id="tocify-subheader-{!! $h2['slug'] !!}" class="tocify-subheader">
@foreach($h2['subheadings'] as $h3)
<li class="tocify-item level-3" data-unique="{!! $h3['slug'] !!}">
<a href="#{!! $h3['slug'] !!}">{!! $h3['name'] !!}</a>
</li>
@endforeach
</ul>
@endif
@endforeach
</ul>
@endif
</ul>
@endforeach
</div>
<ul class="toc-footer" id="toc-footer">
@if($metadata['postman_collection_url'])
<li style="padding-bottom: 5px;"><a href="{!! $metadata['postman_collection_url'] !!}">{!! u::trans("scribe::links.postman") !!}</a></li>
@endif
@if($metadata['openapi_spec_url'])
<li style="padding-bottom: 5px;"><a href="{!! $metadata['openapi_spec_url'] !!}">{!! u::trans("scribe::links.openapi") !!}</a></li>
@endif
<li><a href="http://github.com/knuckleswtf/scribe">Documentation powered by Scribe </a></li>
</ul>
<ul class="toc-footer" id="last-updated">
<li>{{ $metadata['last_updated'] }}</li>
</ul>
</div>

View File

@@ -0,0 +1,70 @@
@php
$hasChildren ??= false;
$isArrayBody = $name == "[]";
$expandable = $hasChildren && !$isArrayBody;
@endphp
<div class="sl-flex sl-relative sl-max-w-full sl-py-2 sl-pl-3">
<div class="sl-w-1 sl-mt-2 sl-mr-3 sl--ml-3 sl-border-t"></div>
<div class="sl-stack sl-stack--vertical sl-stack--1 sl-flex sl-flex-1 sl-flex-col sl-items-stretch sl-max-w-full sl-ml-2 @if($expandable) sl-cursor-pointer @endif">
<div class="sl-flex sl-items-center sl-max-w-full">
@if($expandable)
<div class="sl-flex sl-justify-center sl-w-8 sl--ml-8 sl-pl-3 sl-text-muted expansion-chevrons" role="button">
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="chevron-right"
class="svg-inline--fa fa-chevron-right fa-fw fa-sm sl-icon" role="img"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M96 480c-8.188 0-16.38-3.125-22.62-9.375c-12.5-12.5-12.5-32.75 0-45.25L242.8 256L73.38 86.63c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0l192 192c12.5 12.5 12.5 32.75 0 45.25l-192 192C112.4 476.9 104.2 480 96 480z"></path>
</svg>
</div>
@endif
@unless($isArrayBody)
<div class="sl-flex sl-items-baseline sl-text-base">
<div class="sl-font-mono sl-font-semibold sl-mr-2">{{ $name }}</div>
@if($type)
<span class="sl-truncate sl-text-muted">{{ $type }}</span>
@endif
</div>
@if($required || $deprecated)
<div class="sl-flex-1 sl-h-px sl-mx-3"></div>
<div class="sl-flex sl-items-center">
@if($required)
<span class="sl-ml-2 sl-text-warning">required</span>
@endif
@if($deprecated)
<span class="sl-ml-2 sl-text-warning">deprecated</span>
@endif
</div>
@endif
@endunless
</div>
@if($description)
<div class="sl-prose sl-markdown-viewer" style="font-size: 12px;">
{!! Parsedown::instance()->text($description) !!}
</div>
@endif
@if(!empty($enumValues))
Must be one of:
<ul style="list-style-position: inside; list-style-type: square;">{!! implode(" ", array_map(fn($val) => "<li><code>$val</code></li>", $enumValues)) !!}</ul>
@endif
@if($isArrayBody)
<div class="sl-flex sl-items-baseline sl-text-base">
<div class="sl-font-mono sl-font-semibold sl-mr-2">array of:</div>
@if($required)
<div class="sl-flex-1 sl-h-px sl-mx-3"></div>
<span class="sl-ml-2 sl-text-warning">required</span>
@endif
</div>
@endif
@if(!$hasChildren && !is_null($example) && $example !== '')
<div class="sl-stack sl-stack--horizontal sl-stack--2 sl-flex sl-flex-row sl-items-baseline sl-text-muted">
<span>Example:</span> <!-- <span> important for spacing -->
<div class="sl-flex sl-flex-1 sl-flex-wrap" style="gap: 4px;">
<div class="sl-max-w-full sl-break-all sl-px-1 sl-bg-canvas-tint sl-text-muted sl-rounded sl-border">
{{ is_array($example) || is_bool($example) ? json_encode($example) : $example }}
</div>
</div>
</div>
@endif
</div>
</div>

View File

@@ -0,0 +1,38 @@
@php
$level ??= 0;
$levelNestingClass = match($level) {
0 => "sl-ml-px",
default => "sl-ml-7"
};
$expandable ??= !isset($fields["[]"]);
@endphp
@foreach($fields as $name => $field)
<div class="{{ $expandable ? 'expandable' : '' }} sl-text-sm sl-border-l {{ $levelNestingClass }}">
@component('scribe::themes.elements.components.field-details', [
'name' => $name,
'type' => $field['type'] ?? 'string',
'required' => $field['required'] ?? false,
'deprecated' => $field['deprecated'] ?? false,
'description' => $field['description'] ?? '',
'example' => $field['example'] ?? '',
'enumValues' => $field['enumValues'] ?? null,
'endpointId' => $endpointId,
'hasChildren' => !empty($field['__fields']),
'component' => 'body',
])
@endcomponent
@if(!empty($field['__fields']))
<div class="children" style="{{ $expandable ? 'display: none;' : '' }}">
@component('scribe::themes.elements.components.nested-fields', [
'fields' => $field['__fields'],
'endpointId' => $endpointId,
'level' => $level + 1,
'expandable'=> $expandable,
])
@endcomponent
</div>
@endif
</div>
@endforeach

View File

@@ -0,0 +1,281 @@
@php
use Knuckles\Scribe\Tools\Utils as u;
/** @var Knuckles\Camel\Output\OutputEndpointData $endpoint */
@endphp
<div class="sl-stack sl-stack--vertical sl-stack--8 HttpOperation sl-flex sl-flex-col sl-items-stretch sl-w-full">
<div class="sl-stack sl-stack--vertical sl-stack--5 sl-flex sl-flex-col sl-items-stretch">
<div class="sl-relative">
<div class="sl-stack sl-stack--horizontal sl-stack--5 sl-flex sl-flex-row sl-items-center">
<h2 class="sl-text-3xl sl-leading-tight sl-font-prose sl-text-heading sl-mt-5 sl-mb-1"
id="{!! $endpoint->fullSlug() !!}">
{{ $endpoint->name() }}
</h2>
</div>
</div>
<div class="sl-relative">
<div title="{!! rtrim($baseUrl, '/') . '/'. ltrim($endpoint->uri, '/') !!}"
class="sl-stack sl-stack--horizontal sl-stack--3 sl-inline-flex sl-flex-row sl-items-center sl-max-w-full sl-font-mono sl-py-2 sl-pr-4 sl-bg-canvas-50 sl-rounded-lg"
>
@foreach($endpoint->httpMethods as $method)
<div class="sl-text-lg sl-font-semibold sl-px-2.5 sl-py-1 sl-text-on-primary sl-rounded-lg"
style="background-color: {{ \Knuckles\Scribe\Tools\WritingUtils::$httpMethodToCssColour[$method] }};"
>
{{ $method }}
</div>
@endforeach
<div class="sl-flex sl-overflow-x-hidden sl-text-lg sl-select-all">
<div dir="rtl"
class="sl-overflow-x-hidden sl-truncate sl-text-muted">{!! rtrim($baseUrl, '/') !!}</div>
<div class="sl-flex-1 sl-font-semibold">/{{ ltrim($endpoint->uri, '/') }}</div>
</div>
@if($endpoint->metadata->authenticated)
<div class="sl-font-prose sl-font-semibold sl-px-1.5 sl-py-0.5 sl-text-on-primary sl-rounded-lg"
style="background-color: darkred"
>requires authentication
</div>
@endif
@if($endpoint->metadata->deprecated === true)
<div class="sl-font-prose sl-font-semibold sl-px-1.5 sl-py-0.5 sl-text-on-primary sl-rounded-lg"
style="background-color: darkgoldenrod"
>deprecated
</div>
@endif
@if(is_string($endpoint->metadata->deprecated))
<div class="sl-font-prose sl-font-semibold sl-px-1.5 sl-py-0.5 sl-text-on-primary sl-rounded-lg"
style="background-color: darkgoldenrod"
>deprecated:{{$endpoint->metadata->deprecated}}
</div>
@endif
</div>
</div>
{!! Parsedown::instance()->text($endpoint->metadata->description ?: '') !!}
</div>
<div class="sl-flex">
<div data-testid="two-column-left" class="sl-flex-1 sl-w-0">
<div class="sl-stack sl-stack--vertical sl-stack--10 sl-flex sl-flex-col sl-items-stretch">
<div class="sl-stack sl-stack--vertical sl-stack--8 sl-flex sl-flex-col sl-items-stretch">
@if(count($endpoint->headers))
<div class="sl-stack sl-stack--vertical sl-stack--5 sl-flex sl-flex-col sl-items-stretch">
<h3 class="sl-text-2xl sl-leading-snug sl-font-prose">
{{ u::trans("scribe::endpoint.headers") }}
</h3>
<div class="sl-text-sm">
@foreach($endpoint->headers as $header => $value)
@component('scribe::themes.elements.components.field-details', [
'name' => $header,
'type' => null,
'required' => false,
'deprecated' => false,
'description' => null,
'example' => $value,
'endpointId' => $endpoint->endpointId(),
'component' => 'header',
'isInput' => true,
])
@endcomponent
@endforeach
</div>
</div>
@endif
@if(count($endpoint->urlParameters))
<div class="sl-stack sl-stack--vertical sl-stack--6 sl-flex sl-flex-col sl-items-stretch">
<h3 class="sl-text-2xl sl-leading-snug sl-font-prose">{{ u::trans("scribe::endpoint.url_parameters") }}</h3>
<div class="sl-text-sm">
@foreach($endpoint->urlParameters as $attribute => $parameter)
@component('scribe::themes.elements.components.field-details', [
'name' => $parameter->name,
'type' => $parameter->type ?? 'string',
'required' => $parameter->required,
'deprecated' => $parameter->deprecated,
'description' => $parameter->description,
'example' => $parameter->example ?? '',
'enumValues' => $parameter->enumValues,
'endpointId' => $endpoint->endpointId(),
'component' => 'url',
'isInput' => true,
])
@endcomponent
@endforeach
</div>
</div>
@endif
@if(count($endpoint->queryParameters))
<div class="sl-stack sl-stack--vertical sl-stack--6 sl-flex sl-flex-col sl-items-stretch">
<h3 class="sl-text-2xl sl-leading-snug sl-font-prose">{{ u::trans("scribe::endpoint.query_parameters") }}</h3>
<div class="sl-text-sm">
@foreach($endpoint->queryParameters as $attribute => $parameter)
@component('scribe::themes.elements.components.field-details', [
'name' => $parameter->name,
'type' => $parameter->type,
'required' => $parameter->required,
'deprecated' => $parameter->deprecated,
'description' => $parameter->description,
'example' => $parameter->example ?? '',
'enumValues' => $parameter->enumValues,
'endpointId' => $endpoint->endpointId(),
'component' => 'query',
'isInput' => true,
])
@endcomponent
@endforeach
</div>
</div>
@endif
@if(count($endpoint->nestedBodyParameters))
<div class="sl-stack sl-stack--vertical sl-stack--6 sl-flex sl-flex-col sl-items-stretch">
<h3 class="sl-text-2xl sl-leading-snug sl-font-prose">{{ u::trans("scribe::endpoint.body_parameters") }}</h3>
<div class="sl-text-sm">
@component('scribe::themes.elements.components.nested-fields', [
'fields' => $endpoint->nestedBodyParameters,
'endpointId' => $endpoint->endpointId(),
])
@endcomponent
</div>
</div>
@endif
@if(count($endpoint->responseFields))
<div class="sl-stack sl-stack--vertical sl-stack--6 sl-flex sl-flex-col sl-items-stretch">
<h3 class="sl-text-2xl sl-leading-snug sl-font-prose">{{ u::trans("scribe::endpoint.response_fields") }}</h3>
<div class="sl-text-sm">
@component('scribe::themes.elements.components.nested-fields', [
'fields' => $endpoint->nestedResponseFields,
'endpointId' => $endpoint->endpointId(),
'isInput' => false,
])
@endcomponent
</div>
</div>
@endif
</div>
</div>
</div>
<div data-testid="two-column-right" class="sl-relative sl-w-2/5 sl-ml-16" style="max-width: 500px;">
<div class="sl-stack sl-stack--vertical sl-stack--6 sl-flex sl-flex-col sl-items-stretch">
@if($metadata['try_it_out']['enabled'] ?? false)
@include("scribe::themes.elements.try_it_out")
@endif
@if($metadata['example_languages'])
<div class="sl-panel sl-outline-none sl-w-full sl-rounded-lg">
<div class="sl-panel__titlebar sl-flex sl-items-center sl-relative focus:sl-z-10 sl-text-base sl-leading-none sl-pr-3 sl-pl-4 sl-bg-canvas-200 sl-text-body sl-border-input focus:sl-border-primary sl-select-none">
<div class="sl-flex sl-flex-1 sl-items-center sl-h-lg">
<div class="sl--ml-2">
{{ u::trans("scribe::endpoint.example_request") }}:
<select class="example-request-lang-toggle sl-text-base"
aria-label="Request Sample Language"
onchange="switchExampleLanguage(event.target.value);">
@foreach($metadata['example_languages'] as $language)
<option>{{ $language }}</option>
@endforeach
</select>
</div>
</div>
</div>
@foreach($metadata['example_languages'] as $index => $language)
<div class="sl-bg-canvas-100 example-request example-request-{{ $language }}"
style="{{ $index == 0 ? '' : 'display: none;' }}">
<div class="sl-px-0 sl-py-1">
<div style="max-height: 400px;" class="sl-overflow-y-auto sl-rounded">
@include("scribe::partials.example-requests.$language")
</div>
</div>
</div>
@endforeach
</div>
@endif
@if($endpoint->isGet() || $endpoint->hasResponses())
<div class="sl-panel sl-outline-none sl-w-full sl-rounded-lg">
<div class="sl-panel__titlebar sl-flex sl-items-center sl-relative focus:sl-z-10 sl-text-base sl-leading-none sl-pr-3 sl-pl-4 sl-bg-canvas-200 sl-text-body sl-border-input focus:sl-border-primary sl-select-none">
<div class="sl-flex sl-flex-1 sl-items-center sl-py-2">
<div class="sl--ml-2">
<div class="sl-h-sm sl-text-base sl-font-medium sl-px-1.5 sl-text-muted sl-rounded sl-border-transparent sl-border">
<div class="sl-mb-2 sl-inline-block">{{ u::trans("scribe::endpoint.example_response") }}:</div>
<div class="sl-mb-2 sl-inline-block">
<select
class="example-response-{{ $endpoint->endpointId() }}-toggle sl-text-base"
aria-label="Response sample"
onchange="switchExampleResponse('{{ $endpoint->endpointId() }}', event.target.value);">
@foreach($endpoint->responses as $index => $response)
<option value="{{ $index }}">{{ $response->fullDescription() }}</option>
@endforeach
</select></div>
</div>
</div>
</div>
<button type="button"
class="sl-button sl-h-sm sl-text-base sl-font-medium sl-px-1.5 hover:sl-bg-canvas-50 active:sl-bg-canvas-100 sl-text-muted hover:sl-text-body focus:sl-text-body sl-rounded sl-border-transparent sl-border disabled:sl-opacity-70">
<div class="sl-mx-0">
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="copy"
class="svg-inline--fa fa-copy fa-fw fa-sm sl-icon" role="img"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill="currentColor"
d="M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z"></path>
</svg>
</div>
</button>
</div>
@foreach($endpoint->responses as $index => $response)
<div class="sl-panel__content-wrapper sl-bg-canvas-100 example-response-{{ $endpoint->endpointId() }} example-response-{{ $endpoint->endpointId() }}-{{ $index }}"
style=" {{ $index == 0 ? '' : 'display: none;' }}"
>
<div class="sl-panel__content sl-p-0">@if(count($response->headers))
<details class="sl-pl-2">
<summary style="cursor: pointer; list-style: none;">
<small>
<span class="expansion-chevrons">
<svg aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="chevron-right"
class="svg-inline--fa fa-chevron-right fa-fw sl-icon sl-text-muted"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M96 480c-8.188 0-16.38-3.125-22.62-9.375c-12.5-12.5-12.5-32.75 0-45.25L242.8 256L73.38 86.63c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0l192 192c12.5 12.5 12.5 32.75 0 45.25l-192 192C112.4 476.9 104.2 480 96 480z"></path>
</svg>
</span>
Headers
</small>
</summary>
@php
$headerLines = [];
foreach($response->headers as $header => $value) {
$headerLines[] = $header . ': ' . (is_array($value) ? implode('; ', $value) : $value);
}
@endphp
<pre><code class="language-http">{{ implode("\n", $headerLines) }}</code></pre>
</details>
@endif
@if($response->isBinary())
<pre><code>[{{ u::trans("scribe::endpoint.responses.binary") }}] - {{ htmlentities(str_replace("<<binary>>", "", $response->content)) }}</code></pre>
@elseif($response->status == 204)
<pre><code>[{{ u::trans("scribe::endpoint.responses.empty") }}]</code></pre>
@else
@php($parsed = json_decode($response->content))
{{-- If response is a JSON string, prettify it. Otherwise, just print it --}}
<pre><code style="max-height: 300px;"
class="language-json sl-overflow-x-auto sl-overflow-y-auto">{!! htmlentities($parsed != null ? json_encode($parsed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $response->content) !!}</code></pre>
@endif
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>

View File

@@ -0,0 +1,28 @@
@foreach($groupedEndpoints as $group)
<h1 id="{!! Str::slug($group['name']) !!}"
class="sl-text-5xl sl-leading-tight sl-font-prose sl-text-heading"
>
{!! $group['name'] !!}
</h1>
{!! Parsedown::instance()->text($group['description']) !!}
@foreach($group['subgroups'] as $subgroupName => $subgroup)
@if($subgroupName !== "")
<h2 id="{!! Str::slug($group['name']) !!}-{!! Str::slug($subgroupName) !!}"
class="sl-text-3xl sl-leading-tight sl-font-prose sl-text-heading sl-mt-5 sl-mb-3"
>
{{ $subgroupName }}
</h2>
@php($subgroupDescription = collect($subgroup)->first(fn ($e) => $e->metadata->subgroupDescription)?->metadata?->subgroupDescription)
@if($subgroupDescription)
{!! Parsedown::instance()->text($subgroupDescription) !!}
@endif
<br>
@endif
@foreach($subgroup as $endpoint)
@include("scribe::themes.elements.endpoint")
@endforeach
@endforeach
@endforeach

View File

@@ -0,0 +1,360 @@
@php
use Knuckles\Scribe\Tools\WritingUtils as u;
@endphp
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{!! $metadata['title'] !!}</title>
<link href="https://fonts.googleapis.com/css?family=PT+Sans&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{!! $assetPathPrefix !!}css/theme-elements.style.css" media="screen">
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js"></script>
<link rel="stylesheet"
href="https://unpkg.com/@highlightjs/cdn-assets@11.6.0/styles/docco.min.css">
<script src="https://unpkg.com/@highlightjs/cdn-assets@11.6.0/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
<script type="module">
import {CodeJar} from 'https://medv.io/codejar/codejar.js'
window.CodeJar = CodeJar;
</script>
@if($tryItOut['enabled'] ?? true)
<script>
var tryItOutBaseUrl = "{!! $tryItOut['base_url'] ?? $baseUrl !!}";
var useCsrf = Boolean({{ $tryItOut['use_csrf'] ?? null }});
var csrfUrl = "{{ $tryItOut['csrf_url'] ?? null }}";
</script>
<script src="{{ u::getVersionedAsset($assetPathPrefix.'js/tryitout.js') }}"></script>
<style>
.code-editor, .response-content {
color: whitesmoke;
background-color: transparent;
}
/*
Problem: we want syntax highlighting for the Try It Out JSON body code editor
However, the Try It Out area uses a dark background, while request and response samples
(which are already highlighted) use a light background. HighlightJS can only use one theme per document.
Our options:
1. Change the bg of one. => No, it looks out of place on the page.
2. Use the same highlighting for both. => Nope, one would be unreadable.
3. Copy styles for a dark-bg h1js theme and prefix them for the CodeEditor, which is what we're doing.
Since it's only JSON, we only need a few styles anyway.
Styles taken from the Nord theme: https://github.com/highlightjs/highlight.js/blob/3997c9b430a568d5ad46d96693b90a74fc01ea7f/src/styles/nord.css#L2
*/
.code-editor > .hljs-attr {
color: #8FBCBB;
}
.code-editor > .hljs-string {
color: #A3BE8C;
}
.code-editor > .hljs-number {
color: #B48EAD;
}
.code-editor > .hljs-literal{
color: #81A1C1;
}
</style>
<script>
function tryItOut(btnElement) {
btnElement.disabled = true;
let endpointId = btnElement.dataset.endpoint;
let errorPanel = document.querySelector(`.tryItOut-error[data-endpoint=${endpointId}]`);
errorPanel.hidden = true;
let responsePanel = document.querySelector(`.tryItOut-response[data-endpoint=${endpointId}]`);
responsePanel.hidden = true;
let form = btnElement.form;
let { method, path, hasjsonbody: hasJsonBody} = form.dataset;
let body = {};
if (hasJsonBody === "1") {
body = form.querySelector('.code-editor').textContent;
} else if (form.dataset.hasfiles === "1") {
body = new FormData();
form.querySelectorAll('input[data-component=body]')
.forEach(el => {
if (el.type === 'file') {
if (el.files[0]) body.append(el.name, el.files[0])
} else body.append(el.name, el.value);
});
} else {
form.querySelectorAll('input[data-component=body]').forEach(el => {
_.set(body, el.name, el.value);
});
}
const urlParameters = form.querySelectorAll('input[data-component=url]');
urlParameters.forEach(el => (path = path.replace(new RegExp(`\\{${el.name}\\??}`), el.value)));
const headers = Object.fromEntries(Array.from(form.querySelectorAll('input[data-component=header]'))
.map(el => [el.name, (el.dataset.prefix || '') + el.value]));
const query = {}
form.querySelectorAll('input[data-component=query]').forEach(el => {
_.set(query, el.name, el.value);
});
let preflightPromise = Promise.resolve();
if (window.useCsrf && window.csrfUrl) {
preflightPromise = makeAPICall('GET', window.csrfUrl).then(() => {
headers['X-XSRF-TOKEN'] = getCookie('XSRF-TOKEN');
});
}
// content type has to be unset otherwise file upload won't work
if (form.dataset.hasfiles === "1") {
delete headers['Content-Type'];
}
return preflightPromise.then(() => makeAPICall(method, path, body, query, headers, endpointId))
.then(([responseStatus, statusText, responseContent, responseHeaders]) => {
responsePanel.hidden = false;
responsePanel.querySelector(`.response-status`).textContent = responseStatus + " " + statusText ;
let contentEl = responsePanel.querySelector(`.response-content`);
if (responseContent === '') {
contentEl.textContent = contentEl.dataset.emptyResponseText;
return;
}
// Prettify it if it's JSON
let isJson = false;
try {
const jsonParsed = JSON.parse(responseContent);
if (jsonParsed !== null) {
isJson = true;
responseContent = JSON.stringify(jsonParsed, null, 4);
}
} catch (e) {}
// Replace HTML entities
responseContent = responseContent.replace(/[<>&]/g, (i) => '&#' + i.charCodeAt(0) + ';');
contentEl.innerHTML = responseContent;
isJson && window.hljs.highlightElement(contentEl);
})
.catch(err => {
console.log(err);
let errorMessage = err.message || err;
errorPanel.hidden = false;
errorPanel.querySelector(`.error-message`).textContent = errorMessage;
})
.finally(() => { btnElement.disabled = false } );
}
window.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.tryItOut-btn').forEach(el => {
el.addEventListener('click', () => tryItOut(el));
});
})
</script>
@endif
</head>
<body>
@if($metadata['example_languages'])
<script>
function switchExampleLanguage(lang) {
document.querySelectorAll(`.example-request`).forEach(el => el.style.display = 'none');
document.querySelectorAll(`.example-request-${lang}`).forEach(el => el.style.display = 'initial');
document.querySelectorAll(`.example-request-lang-toggle`).forEach(el => el.value = lang);
}
</script>
@endif
<script>
function switchExampleResponse(endpointId, index) {
document.querySelectorAll(`.example-response-${endpointId}`).forEach(el => el.style.display = 'none');
document.querySelectorAll(`.example-response-${endpointId}-${index}`).forEach(el => el.style.display = 'initial');
document.querySelectorAll(`.example-response-${endpointId}-toggle`).forEach(el => el.value = index);
}
/*
* Requirement: a div with class `expansion-chevrons`
* (or `expansion-chevrons-solid` to use the solid version).
* Also add the `expanded` class if your div is expanded by default.
*/
function toggleExpansionChevrons(evt) {
let elem = evt.currentTarget;
let chevronsArea = elem.querySelector('.expansion-chevrons');
const solid = chevronsArea.classList.contains('expansion-chevrons-solid');
const newState = chevronsArea.classList.contains('expanded') ? 'expand' : 'expanded';
if (newState === 'expanded') {
const selector = solid ? '#expanded-chevron-solid' : '#expanded-chevron';
const template = document.querySelector(selector);
const chevron = template.content.cloneNode(true);
chevronsArea.replaceChildren(chevron);
chevronsArea.classList.add('expanded');
} else {
const selector = solid ? '#expand-chevron-solid' : '#expand-chevron';
const template = document.querySelector(selector);
const chevron = template.content.cloneNode(true);
chevronsArea.replaceChildren(chevron);
chevronsArea.classList.remove('expanded');
}
}
/**
* 1. Make sure the children are inside the parent element
* 2. Add `expandable` class to the parent
* 3. Add `children` class to the children.
* 4. Wrap the default chevron SVG in a div with class `expansion-chevrons`
* (or `expansion-chevrons-solid` to use the solid version).
* Also add the `expanded` class if your div is expanded by default.
*/
function toggleElementChildren(evt) {
let elem = evt.currentTarget;
let children = elem.querySelector(`.children`);
if (!children) return;
if (children.contains(event.target)) return;
let oldState = children.style.display
if (oldState === 'none') {
children.style.removeProperty('display');
toggleExpansionChevrons(evt);
} else {
children.style.display = 'none';
toggleExpansionChevrons(evt);
}
evt.stopPropagation();
}
function highlightSidebarItem(evt = null) {
if (evt && evt.oldURL) {
let oldHash = new URL(evt.oldURL).hash.slice(1);
if (oldHash) {
let previousItem = window['sidebar'].querySelector(`#toc-item-${oldHash}`);
previousItem.classList.remove('sl-bg-primary-tint');
previousItem.classList.add('sl-bg-canvas-100');
}
}
let newHash = location.hash.slice(1);
if (newHash) {
let item = window['sidebar'].querySelector(`#toc-item-${newHash}`);
item.classList.remove('sl-bg-canvas-100');
item.classList.add('sl-bg-primary-tint');
}
}
addEventListener('DOMContentLoaded', () => {
highlightSidebarItem();
document.querySelectorAll('.code-editor').forEach(elem => CodeJar(elem, (editor) => {
// highlight.js does not trim old tags,
// which means highlighting doesn't update on type (only on paste)
// See https://github.com/antonmedv/codejar/issues/18
editor.textContent = editor.textContent
return hljs.highlightElement(editor)
}));
document.querySelectorAll('.expandable').forEach(el => {
el.addEventListener('click', toggleElementChildren);
});
document.querySelectorAll('details').forEach(el => {
el.addEventListener('toggle', toggleExpansionChevrons);
});
});
addEventListener('hashchange', highlightSidebarItem);
</script>
<div class="sl-elements sl-antialiased sl-h-full sl-text-base sl-font-ui sl-text-body sl-flex sl-inset-0">
@include("scribe::themes.elements.sidebar")
<div class="sl-overflow-y-auto sl-flex-1 sl-w-full sl-px-16 sl-bg-canvas sl-py-16" style="max-width: 1500px;">
<div class="sl-mb-10">
<div class="sl-mb-4">
<h1 class="sl-text-5xl sl-leading-tight sl-font-prose sl-font-semibold sl-text-heading">
{!! $metadata['title'] !!}
</h1>
@if($metadata['postman_collection_url'])
<a title="Download Postman collection" class="sl-mx-1"
href="{!! $metadata['postman_collection_url'] !!}" target="_blank">
<small>Postman collection </small>
</a>
@endif
@if($metadata['openapi_spec_url'])
<a title="Download OpenAPI spec" class="sl-mx-1"
href="{!! $metadata['openapi_spec_url'] !!}" target="_blank">
<small>OpenAPI spec </small>
</a>
@endif
</div>
<div class="sl-prose sl-markdown-viewer sl-my-4">
{!! $intro !!}
{!! $auth !!}
</div>
</div>
@include("scribe::themes.elements.groups")
<div class="sl-prose sl-markdown-viewer sl-my-5">
{!! $append !!}
</div>
</div>
</div>
<template id="expand-chevron">
<svg aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="chevron-right"
class="svg-inline--fa fa-chevron-right fa-fw sl-icon sl-text-muted"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M96 480c-8.188 0-16.38-3.125-22.62-9.375c-12.5-12.5-12.5-32.75 0-45.25L242.8 256L73.38 86.63c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0l192 192c12.5 12.5 12.5 32.75 0 45.25l-192 192C112.4 476.9 104.2 480 96 480z"></path>
</svg>
</template>
<template id="expanded-chevron">
<svg aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="chevron-down"
class="svg-inline--fa fa-chevron-down fa-fw sl-icon sl-text-muted"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<path fill="currentColor"
d="M224 416c-8.188 0-16.38-3.125-22.62-9.375l-192-192c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L224 338.8l169.4-169.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-192 192C240.4 412.9 232.2 416 224 416z"></path>
</svg>
</template>
<template id="expand-chevron-solid">
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-right"
class="svg-inline--fa fa-caret-right fa-fw sl-icon" role="img" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 512">
<path fill="currentColor"
d="M118.6 105.4l128 127.1C252.9 239.6 256 247.8 256 255.1s-3.125 16.38-9.375 22.63l-128 127.1c-9.156 9.156-22.91 11.9-34.88 6.943S64 396.9 64 383.1V128c0-12.94 7.781-24.62 19.75-29.58S109.5 96.23 118.6 105.4z"></path>
</svg>
</template>
<template id="expanded-chevron-solid">
<svg aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="caret-down"
class="svg-inline--fa fa-caret-down fa-fw sl-icon" role="img"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M310.6 246.6l-127.1 128C176.4 380.9 168.2 384 160 384s-16.38-3.125-22.63-9.375l-127.1-128C.2244 237.5-2.516 223.7 2.438 211.8S19.07 192 32 192h255.1c12.94 0 24.62 7.781 29.58 19.75S319.8 237.5 310.6 246.6z"></path>
</svg>
</template>
</body>
</html>

View File

@@ -0,0 +1,92 @@
<div id="sidebar" class="sl-flex sl-overflow-y-auto sl-flex-col sl-sticky sl-inset-y-0 sl-pt-8 sl-bg-canvas-100 sl-border-r"
style="width: calc((100% - 1800px) / 2 + 300px); padding-left: calc((100% - 1800px) / 2); min-width: 300px; max-height: 100vh">
<div class="sl-flex sl-items-center sl-mb-5 sl-ml-4">
@if($metadata['logo'] != false)
<div class="sl-inline sl-overflow-x-hidden sl-overflow-y-hidden sl-mr-3 sl-rounded-lg"
style="background-color: transparent;">
<img src="{{ $metadata['logo'] }}" height="30px" width="30px" alt="logo">
</div>
@endif
<h4 class="sl-text-paragraph sl-leading-snug sl-font-prose sl-font-semibold sl-text-heading">
{{ $metadata['title'] }}
</h4>
</div>
<div class="sl-flex sl-overflow-y-auto sl-flex-col sl-flex-grow sl-flex-shrink">
<div class="sl-overflow-y-auto sl-w-full sl-bg-canvas-100">
<div class="sl-my-3">
@foreach($headings as $h1)
<div class="expandable">
<div title="{!! $h1['name'] !!}" id="toc-item-{!! $h1['slug'] !!}"
class="sl-flex sl-items-center sl-h-md sl-pr-4 sl-pl-4 sl-bg-canvas-100 hover:sl-bg-canvas-200 sl-cursor-pointer sl-select-none">
<a href="#{!! $h1['slug'] !!}"
class="sl-flex-1 sl-items-center sl-truncate sl-mr-1.5 sl-p-0">{!! $h1['name'] !!}</a>
@if(count($h1['subheadings']) > 0)
<div class="sl-flex sl-items-center sl-text-xs expansion-chevrons">
<svg aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="chevron-right"
class="svg-inline--fa fa-chevron-right fa-fw sl-icon sl-text-muted"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M96 480c-8.188 0-16.38-3.125-22.62-9.375c-12.5-12.5-12.5-32.75 0-45.25L242.8 256L73.38 86.63c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0l192 192c12.5 12.5 12.5 32.75 0 45.25l-192 192C112.4 476.9 104.2 480 96 480z"></path>
</svg>
</div>
@endif
</div>
@if(count($h1['subheadings']) > 0)
<div class="children" style="display: none;">
@foreach($h1['subheadings'] as $h2)
<div class="expandable">
<div class="sl-flex sl-items-center sl-h-md sl-pr-4 sl-pl-8 sl-bg-canvas-100 hover:sl-bg-canvas-200 sl-cursor-pointer sl-select-none"
id="toc-item-{!! $h2['slug'] !!}">
<div class="sl-flex-1 sl-items-center sl-truncate sl-mr-1.5 sl-p-0" title="{!! $h2['name'] !!}">
<a class="ElementsTableOfContentsItem sl-block sl-no-underline"
href="#{!! $h2['slug'] !!}">
{!! $h2['name'] !!}
</a>
</div>
@if(count($h2['subheadings']) > 0)
<div class="sl-flex sl-items-center sl-text-xs expansion-chevrons">
<svg aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="chevron-right"
class="svg-inline--fa fa-chevron-right fa-fw sl-icon sl-text-muted"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M96 480c-8.188 0-16.38-3.125-22.62-9.375c-12.5-12.5-12.5-32.75 0-45.25L242.8 256L73.38 86.63c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0l192 192c12.5 12.5 12.5 32.75 0 45.25l-192 192C112.4 476.9 104.2 480 96 480z"></path>
</svg>
</div>
@endif
</div>
@if(count($h2['subheadings']) > 0)
<div class="children" style="display: none;">
@foreach($h2['subheadings'] as $h3)
<a class="ElementsTableOfContentsItem sl-block sl-no-underline"
href="#{!! $h3['slug'] !!}">
<div title="{!! $h3['name'] !!}" id="toc-item-{!! $h3['slug'] !!}"
class="sl-flex sl-items-center sl-h-md sl-pr-4 sl-pl-12 sl-bg-canvas-100 hover:sl-bg-canvas-200 sl-cursor-pointer sl-select-none">
{!! $h3['name'] !!}
</div>
</a>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
</div>
<div class="sl-flex sl-items-center sl-px-4 sl-py-3 sl-border-t">
{{ $metadata['last_updated'] }}
</div>
<div class="sl-flex sl-items-center sl-px-4 sl-py-3 sl-border-t">
<a href="http://github.com/knuckleswtf/scribe">Documentation powered by Scribe </a>
</div>
</div>
</div>

View File

@@ -0,0 +1,322 @@
@php
use Knuckles\Scribe\Tools\Utils as u;
/** @var \Knuckles\Camel\Output\OutputEndpointData $endpoint */
@endphp
<div class="sl-inverted">
<div class="sl-overflow-y-hidden sl-rounded-lg">
<form class="TryItPanel sl-bg-canvas-100 sl-rounded-lg"
data-method="{{ $endpoint->httpMethods[0] }}"
data-path="{{ $endpoint->uri }}"
data-hasfiles="{{ $endpoint->hasFiles() ? 1 : 0 }}"
data-hasjsonbody="{{ $endpoint->hasJsonBody() ? 1 : 0 }}">
@if($endpoint->isAuthed() && $metadata['auth']['location'] !== 'body')
<div class="sl-panel sl-outline-none sl-w-full expandable">
<div class="sl-panel__titlebar sl-flex sl-items-center sl-relative focus:sl-z-10 sl-text-base sl-leading-none sl-pr-4 sl-pl-3 sl-bg-canvas-200 sl-text-body sl-border-input focus:sl-border-primary sl-cursor-pointer sl-select-none"
role="button">
<div class="sl-flex sl-flex-1 sl-items-center sl-h-lg">
<div class="sl-flex sl-items-center sl-mr-1.5 expansion-chevrons expansion-chevrons-solid expanded">
<svg aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="caret-down"
class="svg-inline--fa fa-caret-down fa-fw sl-icon" role="img"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M310.6 246.6l-127.1 128C176.4 380.9 168.2 384 160 384s-16.38-3.125-22.63-9.375l-127.1-128C.2244 237.5-2.516 223.7 2.438 211.8S19.07 192 32 192h255.1c12.94 0 24.62 7.781 29.58 19.75S319.8 237.5 310.6 246.6z"></path>
</svg>
</div>
Auth
</div>
</div>
<div class="sl-panel__content-wrapper sl-bg-canvas-100 children" role="region">
<div class="ParameterGrid sl-p-4">
<label aria-hidden="true"
for="auth-{{ $endpoint->endpointId() }}">{{ $metadata['auth']['name'] }}</label>
<span class="sl-mx-3">:</span>
<div class="sl-flex sl-flex-1">
<div class="sl-input sl-flex-1 sl-relative">
<code>{{ $metadata['auth']['prefix'] }}</code>
<input aria-label="{{ $metadata['auth']['name'] }}"
id="auth-{{ $endpoint->endpointId() }}"
data-component="{{ $metadata['auth']['location'] }}"
data-prefix="{{ $metadata['auth']['prefix'] }}"
name="{{ $metadata['auth']['name'] }}"
placeholder="{{ $metadata['auth']['placeholder'] }}"
class="auth-value sl-relative {{ $metadata['auth']['prefix'] ? 'sl-w-3/5' : 'sl-w-full sl-pr-2.5 sl-pl-2.5' }} sl-h-md sl-text-base sl-rounded sl-border-transparent hover:sl-border-input focus:sl-border-primary sl-border">
</div>
</div>
</div>
</div>
</div>
@endif
@if(count($endpoint->headers))
<div class="sl-panel sl-outline-none sl-w-full expandable">
<div class="sl-panel__titlebar sl-flex sl-items-center sl-relative focus:sl-z-10 sl-text-base sl-leading-none sl-pr-4 sl-pl-3 sl-bg-canvas-200 sl-text-body sl-border-input focus:sl-border-primary sl-cursor-pointer sl-select-none"
role="button">
<div class="sl-flex sl-flex-1 sl-items-center sl-h-lg">
<div class="sl-flex sl-items-center sl-mr-1.5 expansion-chevrons expansion-chevrons-solid expanded">
<svg aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="caret-down"
class="svg-inline--fa fa-caret-down fa-fw sl-icon" role="img"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M310.6 246.6l-127.1 128C176.4 380.9 168.2 384 160 384s-16.38-3.125-22.63-9.375l-127.1-128C.2244 237.5-2.516 223.7 2.438 211.8S19.07 192 32 192h255.1c12.94 0 24.62 7.781 29.58 19.75S319.8 237.5 310.6 246.6z"></path>
</svg>
</div>
Headers
</div>
</div>
<div class="sl-panel__content-wrapper sl-bg-canvas-100 children" role="region">
<div class="ParameterGrid sl-p-4">
@foreach($endpoint->headers as $name => $example)
@php
if($endpoint->isAuthed() && $metadata['auth']['location'] === 'header' && $name === $metadata['auth']['name']) continue;
@endphp
<label aria-hidden="true"
for="header-{{ $endpoint->endpointId() }}-{{ $name }}">{{ $name }}</label>
<span class="sl-mx-3">:</span>
<div class="sl-flex sl-flex-1">
<div class="sl-input sl-flex-1 sl-relative">
<input aria-label="{{ $name }}" name="{{ $name }}"
id="header-{{ $endpoint->endpointId() }}-{{ $name }}"
value="{{ $example }}" data-component="header"
class="sl-relative sl-w-full sl-h-md sl-text-base sl-pr-2.5 sl-pl-2.5 sl-rounded sl-border-transparent hover:sl-border-input focus:sl-border-primary sl-border">
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
@if(count($endpoint->urlParameters))
<div class="sl-panel sl-outline-none sl-w-full expandable">
<div class="sl-panel__titlebar sl-flex sl-items-center sl-relative focus:sl-z-10 sl-text-base sl-leading-none sl-pr-4 sl-pl-3 sl-bg-canvas-200 sl-text-body sl-border-input focus:sl-border-primary sl-cursor-pointer sl-select-none"
role="button">
<div class="sl-flex sl-flex-1 sl-items-center sl-h-lg">
<div class="sl-flex sl-items-center sl-mr-1.5 expansion-chevrons expansion-chevrons-solid expanded">
<svg aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="caret-down"
class="svg-inline--fa fa-caret-down fa-fw sl-icon" role="img"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M310.6 246.6l-127.1 128C176.4 380.9 168.2 384 160 384s-16.38-3.125-22.63-9.375l-127.1-128C.2244 237.5-2.516 223.7 2.438 211.8S19.07 192 32 192h255.1c12.94 0 24.62 7.781 29.58 19.75S319.8 237.5 310.6 246.6z"></path>
</svg>
</div>
URL Parameters
</div>
</div>
<div class="sl-panel__content-wrapper sl-bg-canvas-100 children" role="region">
<div class="ParameterGrid sl-p-4">
@foreach($endpoint->urlParameters as $name => $parameter)
<label aria-hidden="true"
for="urlparam-{{ $endpoint->endpointId() }}-{{ $name }}">{{ $name }}</label>
<span class="sl-mx-3">:</span>
<div class="sl-flex sl-flex-1">
<div class="sl-input sl-flex-1 sl-relative">
<input aria-label="{{ $name }}" name="{{ $name }}"
id="urlparam-{{ $endpoint->endpointId() }}-{{ $name }}"
placeholder="{{ $parameter->description }}"
value="{{ $parameter->example }}" data-component="url"
class="sl-relative sl-w-full sl-h-md sl-text-base sl-pr-2.5 sl-pl-2.5 sl-rounded sl-border-transparent hover:sl-border-input focus:sl-border-primary sl-border">
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
@if(count($endpoint->queryParameters))
<div class="sl-panel sl-outline-none sl-w-full expandable">
<div class="sl-panel__titlebar sl-flex sl-items-center sl-relative focus:sl-z-10 sl-text-base sl-leading-none sl-pr-4 sl-pl-3 sl-bg-canvas-200 sl-text-body sl-border-input focus:sl-border-primary sl-cursor-pointer sl-select-none"
role="button">
<div class="sl-flex sl-flex-1 sl-items-center sl-h-lg">
<div class="sl-flex sl-items-center sl-mr-1.5 expansion-chevrons expansion-chevrons-solid expanded">
<svg aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="caret-down"
class="svg-inline--fa fa-caret-down fa-fw sl-icon" role="img"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M310.6 246.6l-127.1 128C176.4 380.9 168.2 384 160 384s-16.38-3.125-22.63-9.375l-127.1-128C.2244 237.5-2.516 223.7 2.438 211.8S19.07 192 32 192h255.1c12.94 0 24.62 7.781 29.58 19.75S319.8 237.5 310.6 246.6z"></path>
</svg>
</div>
Query Parameters
</div>
</div>
<div class="sl-panel__content-wrapper sl-bg-canvas-100 children" role="region">
<div class="ParameterGrid sl-p-4">
@foreach($endpoint->queryParameters as $name => $parameter)
@php
/** @var \Knuckles\Camel\Output\Parameter $parameter */
if ($parameter->type == 'object') // Skip; individual object children are listed
continue;
if (str_contains($name, "[]"))
// This likely belongs to an obj-array (eg objs[].a); we only show the parent (objs[]), so skip
continue;
if($endpoint->isAuthed() && $metadata['auth']['location'] === 'query'
&& $name === $metadata['auth']['name']) continue;
@endphp
<label aria-hidden="true"
for="queryparam-{{ $endpoint->endpointId() }}-{{ $name }}">{{ $name }}</label>
<span class="sl-mx-3">:</span>
<div class="sl-flex sl-flex-1">
<div class="sl-input sl-flex-1 sl-relative">
@if(str_ends_with($parameter->type, '[]'))
<input aria-label="{{ $name }}" name="{{ $name }}"
id="queryparam-{{ $endpoint->endpointId() }}-{{ $name }}"
placeholder="{{ $parameter->description }}"
value="{{ json_encode($parameter->example, JSON_HEX_TAG | JSON_HEX_AMP) }}" data-component="query"
class="sl-relative sl-w-full sl-h-md sl-text-base sl-pr-2.5 sl-pl-2.5 sl-rounded sl-border-transparent hover:sl-border-input focus:sl-border-primary sl-border"
>
@else
<input aria-label="{{ $name }}" name="{{ $name }}"
id="queryparam-{{ $endpoint->endpointId() }}-{{ $name }}"
placeholder="{{ $parameter->description }}"
value="{{ $parameter->example }}" data-component="query"
class="sl-relative sl-w-full sl-h-md sl-text-base sl-pr-2.5 sl-pl-2.5 sl-rounded sl-border-transparent hover:sl-border-input focus:sl-border-primary sl-border"
>
@endif
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
@if(count($endpoint->bodyParameters))
<div class="sl-panel sl-outline-none sl-w-full expandable">
<div class="sl-panel__titlebar sl-flex sl-items-center sl-relative focus:sl-z-10 sl-text-base sl-leading-none sl-pr-4 sl-pl-3 sl-bg-canvas-200 sl-text-body sl-border-input focus:sl-border-primary sl-cursor-pointer sl-select-none"
role="button">
<div class="sl-flex sl-flex-1 sl-items-center sl-h-lg">
<div class="sl-flex sl-items-center sl-mr-1.5 expansion-chevrons expansion-chevrons-solid expanded">
<svg aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="caret-down"
class="svg-inline--fa fa-caret-down fa-fw sl-icon" role="img"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M310.6 246.6l-127.1 128C176.4 380.9 168.2 384 160 384s-16.38-3.125-22.63-9.375l-127.1-128C.2244 237.5-2.516 223.7 2.438 211.8S19.07 192 32 192h255.1c12.94 0 24.62 7.781 29.58 19.75S319.8 237.5 310.6 246.6z"></path>
</svg>
</div>
Body
</div>
</div>
<div class="sl-panel__content-wrapper sl-bg-canvas-100 children" role="region">
@if($endpoint->hasJsonBody())
<div class="TextRequestBody sl-p-4">
<div class="code-editor language-json"
id="json-body-{{ $endpoint->endpointId() }}"
style="font-family: var(--font-code); font-size: 12px; line-height: var(--lh-code);"
>{!! json_encode($endpoint->getSampleBody(), JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP) !!}</div>
</div>
@else
<div class="ParameterGrid sl-p-4">
@foreach($endpoint->bodyParameters as $name => $parameter)
@php
/** @var \Knuckles\Camel\Output\Parameter $parameter */
if ($parameter->type == 'object') // Skip; individual object children are listed
continue;
if (str_contains($name, "[]"))
// This likely belongs to an obj-array (eg objs[].a); we only show the parent (objs[]), so skip
continue;
@endphp
<label aria-hidden="true"
for="bodyparam-{{ $endpoint->endpointId() }}-{{ $name }}">{{ $name }}</label>
<span class="sl-mx-3">:</span>
<div class="sl-flex sl-flex-1">
<div class="sl-input sl-flex-1 sl-relative">
@if($parameter->type == 'file')
<input aria-label="{{ $name }}" name="{{ $name }}"
id="bodyparam-{{ $endpoint->endpointId() }}-{{ $name }}"
type="file" data-component="body"
class="sl-relative sl-w-full sl-h-md sl-text-base sl-pr-2.5 sl-pl-2.5 sl-rounded sl-border-transparent hover:sl-border-input focus:sl-border-primary sl-border"
>
@elseif(str_ends_with($parameter->type, '[]'))
<input aria-label="{{ $name }}" name="{{ $name }}"
id="bodyparam-{{ $endpoint->endpointId() }}-{{ $name }}"
placeholder="{{ $parameter->description }}"
value="{{ json_encode($parameter->example, JSON_HEX_TAG | JSON_HEX_AMP) }}" data-component="body"
class="sl-relative sl-w-full sl-h-md sl-text-base sl-pr-2.5 sl-pl-2.5 sl-rounded sl-border-transparent hover:sl-border-input focus:sl-border-primary sl-border"
>
@else
<input aria-label="{{ $name }}" name="{{ $name }}"
id="bodyparam-{{ $endpoint->endpointId() }}-{{ $name }}"
placeholder="{{ $parameter->description }}"
value="{{ $parameter->example }}" data-component="body"
class="sl-relative sl-w-full sl-h-md sl-text-base sl-pr-2.5 sl-pl-2.5 sl-rounded sl-border-transparent hover:sl-border-input focus:sl-border-primary sl-border"
>
@endif
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
@endif
<div class="SendButtonHolder sl-mt-4 sl-p-4 sl-pt-0">
<div class="sl-stack sl-stack--horizontal sl-stack--2 sl-flex sl-flex-row sl-items-center">
<button type="button" data-endpoint="{{ $endpoint->endpointId() }}"
class="tryItOut-btn sl-button sl-h-sm sl-text-base sl-font-medium sl-px-1.5 sl-bg-primary hover:sl-bg-primary-dark active:sl-bg-primary-darker disabled:sl-bg-canvas-100 sl-text-on-primary disabled:sl-text-body sl-rounded sl-border-transparent sl-border disabled:sl-opacity-70"
>
{{ u::trans("scribe::try_it_out.send") }}
</button>
</div>
</div>
<div data-endpoint="{{ $endpoint->endpointId() }}"
class="tryItOut-error expandable sl-panel sl-outline-none sl-w-full" hidden>
<div class="sl-panel__titlebar sl-flex sl-items-center sl-relative focus:sl-z-10 sl-text-base sl-leading-none sl-pr-4 sl-pl-3 sl-bg-canvas-200 sl-text-body sl-border-input focus:sl-border-primary sl-cursor-pointer sl-select-none"
role="button">
<div class="sl-flex sl-flex-1 sl-items-center sl-h-lg">
<div class="sl-flex sl-items-center sl-mr-1.5 expansion-chevrons expansion-chevrons-solid expanded">
<svg aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="caret-down"
class="svg-inline--fa fa-caret-down fa-fw sl-icon" role="img"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M310.6 246.6l-127.1 128C176.4 380.9 168.2 384 160 384s-16.38-3.125-22.63-9.375l-127.1-128C.2244 237.5-2.516 223.7 2.438 211.8S19.07 192 32 192h255.1c12.94 0 24.62 7.781 29.58 19.75S319.8 237.5 310.6 246.6z"></path>
</svg>
</div>
{{ u::trans("scribe::try_it_out.request_failed") }}
</div>
</div>
<div class="sl-panel__content-wrapper sl-bg-canvas-100 children" role="region">
<div class="sl-panel__content sl-p-4">
<p class="sl-pb-2"><strong class="error-message"></strong></p>
<p class="sl-pb-2">{{ u::trans("scribe::try_it_out.error_help") }}</p>
</div>
</div>
</div>
<div data-endpoint="{{ $endpoint->endpointId() }}"
class="tryItOut-response expandable sl-panel sl-outline-none sl-w-full" hidden>
<div class="sl-panel__titlebar sl-flex sl-items-center sl-relative focus:sl-z-10 sl-text-base sl-leading-none sl-pr-4 sl-pl-3 sl-bg-canvas-200 sl-text-body sl-border-input focus:sl-border-primary sl-cursor-pointer sl-select-none"
role="button">
<div class="sl-flex sl-flex-1 sl-items-center sl-h-lg">
<div class="sl-flex sl-items-center sl-mr-1.5 expansion-chevrons expansion-chevrons-solid expanded">
<svg aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="caret-down"
class="svg-inline--fa fa-caret-down fa-fw sl-icon" role="img"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M310.6 246.6l-127.1 128C176.4 380.9 168.2 384 160 384s-16.38-3.125-22.63-9.375l-127.1-128C.2244 237.5-2.516 223.7 2.438 211.8S19.07 192 32 192h255.1c12.94 0 24.62 7.781 29.58 19.75S319.8 237.5 310.6 246.6z"></path>
</svg>
</div>
{{ u::trans("scribe::try_it_out.received_response") }}
</div>
</div>
<div class="sl-panel__content-wrapper sl-bg-canvas-100 children" role="region">
<div class="sl-panel__content sl-p-4">
<p class="sl-pb-2 response-status"></p>
<pre><code class="sl-pb-2 response-content language-json"
data-empty-response-text="<{{ u::trans("scribe::endpoint.responses.empty") }}>"
style="max-height: 300px;"></code></pre>
</div>
</div>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,21 @@
<?php
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
$prefix = config('scribe.laravel.docs_url', '/docs');
$middleware = config('scribe.laravel.middleware', []);
Route::middleware($middleware)
->group(function () use ($prefix) {
Route::view($prefix, 'scribe.index')->name('scribe');
Route::get("{$prefix}.postman", function () {
return new JsonResponse(Storage::disk('local')->get('scribe/collection.json'), json: true);
})->name('scribe.postman');
Route::get("{$prefix}.openapi", function () {
return response()->file(Storage::disk('local')->path('scribe/openapi.yaml'));
})->name('scribe.openapi');
});

View File

@@ -0,0 +1,16 @@
<?php
namespace Knuckles\Scribe\Attributes;
#[\Attribute(\Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class Authenticated
{
public function __construct(
public ?bool $authenticated = true,
) {}
public function toArray()
{
return ['authenticated' => $this->authenticated];
}
}

View File

@@ -0,0 +1,6 @@
<?php
namespace Knuckles\Scribe\Attributes;
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class BodyParam extends GenericParam {}

View File

@@ -0,0 +1,16 @@
<?php
namespace Knuckles\Scribe\Attributes;
#[\Attribute(\Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class Deprecated
{
public function __construct(
public bool|string|null $deprecated = true,
) {}
public function toArray()
{
return ['deprecated' => $this->deprecated];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Knuckles\Scribe\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
class Endpoint
{
public function __construct(
public string $title,
public ?string $description = '',
/** You can use the separate #[Authenticated] attribute, or pass authenticated: false to this. */
public ?bool $authenticated = null,
) {}
public function toArray()
{
$data = [
'title' => $this->title,
'description' => $this->description,
];
if (! is_null($this->authenticated)) {
$data['authenticated'] = $this->authenticated;
}
return $data;
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Knuckles\Scribe\Attributes;
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class GenericParam
{
public function __construct(
public string $name,
public ?string $type = 'string',
public ?string $description = '',
public ?bool $required = true,
public mixed $example = null, // Pass 'No-example' to omit the example
public mixed $enum = null, // Can pass a list of values, or a native PHP enum
public ?bool $nullable = false,
public ?bool $deprecated = false,
) {}
public function toArray()
{
return [
'name' => $this->name,
'description' => $this->description,
'type' => $this->type,
'required' => $this->required,
'example' => $this->example,
'enumValues' => $this->getEnumValues(),
'nullable' => $this->nullable,
'deprecated' => $this->deprecated,
];
}
protected function getEnumValues(): array
{
if (! $this->enum) {
return [];
}
if (is_array($this->enum)) {
return $this->enum;
}
if (enum_exists($this->enum) && method_exists($this->enum, 'tryFrom')) {
return array_map(
// $case->value only exists on BackedEnums, not UnitEnums
// method_exists($enum, 'tryFrom') implies the enum is a BackedEnum
// @phpstan-ignore-next-line
fn ($case) => $case->value,
$this->enum::cases()
);
}
throw new \InvalidArgumentException(
'The enum property of a parameter must be either a PHP enum or an array of values'
);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Knuckles\Scribe\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
class Group
{
public function __construct(
public mixed $name,
public ?string $description = '',
/** You can use the separate #[Authenticated] attribute, or pass authenticated: false to this. */
public ?bool $authenticated = null,
) {}
public function toArray()
{
$data = [
'groupName' => $this->getName(),
'groupDescription' => $this->description,
];
if (! is_null($this->authenticated)) {
$data['authenticated'] = $this->authenticated;
}
return $data;
}
protected function getName(): string
{
if (is_string($this->name)) {
return $this->name;
}
if (interface_exists('BackedEnum') && is_a($this->name, 'BackedEnum')) {
return $this->name->value;
}
throw new \InvalidArgumentException(
'The name property of a group must be either a PHP Backed Enum or a string'
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Knuckles\Scribe\Attributes;
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class Header
{
public function __construct(
public string $name,
public mixed $example = null,
) {}
public function toArray()
{
return [
'name' => $this->name,
'example' => $this->example,
];
}
}

View File

@@ -0,0 +1,6 @@
<?php
namespace Knuckles\Scribe\Attributes;
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class QueryParam extends GenericParam {}

View File

@@ -0,0 +1,26 @@
<?php
namespace Knuckles\Scribe\Attributes;
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class Response
{
public function __construct(
public array|string|null $content = null,
public int $status = 200,
public ?string $description = '',
) {}
public function toArray()
{
return [
'status' => $this->status,
'content' => match (true) {
is_null($this->content) => null,
is_string($this->content) => $this->content,
default => json_encode($this->content, JSON_THROW_ON_ERROR),
},
'description' => $this->description,
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Knuckles\Scribe\Attributes;
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class ResponseField extends GenericParam
{
// Don't default to string; type inference is currently handled by the normalizer
// TODO change this in the future
public function __construct(
public string $name,
public ?string $type = null,
public ?string $description = '',
public ?bool $required = true,
public mixed $example = null, // Pass 'No-example' to omit the example
public mixed $enum = null, // Can pass a list of values, or a native PHP enum,
public ?bool $nullable = false,
public ?bool $deprecated = false,
) {}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Knuckles\Scribe\Attributes;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Knuckles\Scribe\Extracting\Shared\ApiResourceResponseTools;
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class ResponseFromApiResource
{
public function __construct(
public string $name,
public ?string $model = null,
public int $status = 200,
public ?string $description = '',
// Mark if this should be used as a collection. Only needed if not using a ResourceCollection.
public ?bool $collection = null,
public array $factoryStates = [],
public array $with = [],
public ?int $paginate = null,
public ?int $simplePaginate = null,
public ?int $cursorPaginate = null,
public array $additional = [],
public array $withCount = [],
) {}
public function modelToBeTransformed(): ?string
{
if (! empty($this->model)) {
return $this->model;
}
return ApiResourceResponseTools::tryToInferApiResourceModel($this->name);
}
public function isCollection(): bool
{
if (! is_null($this->collection)) {
return $this->collection;
}
return is_subclass_of($this->name, ResourceCollection::class);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Knuckles\Scribe\Attributes;
use Knuckles\Scribe\Extracting\Shared\ResponseFileTools;
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class ResponseFromFile
{
public function __construct(
public string $file,
public int $status = 200,
public array|string $merge = [],
public ?string $description = '',
) {}
public function toArray()
{
return [
'status' => $this->status,
'description' => $this->description,
'content' => ResponseFileTools::getResponseContents($this->file, $this->merge),
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Knuckles\Scribe\Attributes;
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class ResponseFromTransformer
{
public function __construct(
public string $name,
public ?string $model = null,
public int $status = 200,
public ?string $description = '',
// Mark if this should be used as a collection. Only needed if not using a CollectionTransformer.
public bool $collection = false,
public array $factoryStates = [],
public array $with = [],
public ?string $resourceKey = null,
// Format: [adapter, numberPerPage]. Example: [SomePaginator::class, 10]
public array $paginate = [],
) {}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Knuckles\Scribe\Attributes;
#[\Attribute(\Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class Subgroup
{
public function __construct(
public mixed $name,
public ?string $description = '',
) {}
public function toArray()
{
return [
'subgroup' => $this->getName(),
'subgroupDescription' => $this->description,
];
}
protected function getName(): string
{
if (is_string($this->name)) {
return $this->name;
}
if (interface_exists('BackedEnum') && is_a($this->name, 'BackedEnum')) {
return $this->name->value;
}
throw new \InvalidArgumentException(
'The name property of a subgroup must be either a PHP Backed Enum or a string'
);
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Knuckles\Scribe\Attributes;
#[\Attribute(\Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class Unauthenticated
{
public function toArray()
{
return ['authenticated' => false];
}
}

View File

@@ -0,0 +1,6 @@
<?php
namespace Knuckles\Scribe\Attributes;
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
class UrlParam extends GenericParam {}

View File

@@ -0,0 +1,35 @@
<?php
namespace Knuckles\Scribe\Commands;
use Illuminate\Console\Command;
use Knuckles\Scribe\Tools\ConfigDiffer;
class DiffConfig extends Command
{
protected $signature = 'scribe:config:diff {--config=scribe : choose which config file to use}';
protected $description = 'Dump your changed config to the console. Use this when posting bug reports';
public function handle(): void
{
$usersConfig = config($this->option('config'));
$defaultConfig = require __DIR__.'/../../config/scribe.php';
$ignore = ['example_languages', 'routes', 'description', 'auth.extra_info', 'intro_text', 'groups'];
$asList = ['strategies.*', 'examples.models_source'];
$differ = new ConfigDiffer(original: $defaultConfig, changed: $usersConfig, ignorePaths: $ignore, asList: $asList);
$diff = $differ->getDiff();
if (empty($diff)) {
$this->info('------ SAME AS DEFAULT CONFIG ------');
return;
}
foreach ($diff as $key => $item) {
$this->line("{$key} => {$item}");
}
}
}

View File

@@ -0,0 +1,257 @@
<?php
namespace Knuckles\Scribe\Commands;
use Illuminate\Console\Command;
use Illuminate\Console\View\Components\Factory;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Knuckles\Camel\Camel;
use Knuckles\Scribe\GroupedEndpoints\GroupedEndpointsFactory;
use Knuckles\Scribe\Matching\RouteMatcherInterface;
use Knuckles\Scribe\Scribe;
use Knuckles\Scribe\Tools\ConfigDiffer;
use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
use Knuckles\Scribe\Tools\DocumentationConfig;
use Knuckles\Scribe\Tools\ErrorHandlingUtils as e;
use Knuckles\Scribe\Tools\Globals;
use Knuckles\Scribe\Tools\PathConfig;
use Knuckles\Scribe\Writing\Writer;
class GenerateDocumentation extends Command
{
protected $signature = "scribe:generate
{--force : Discard any changes you've made to the YAML or Markdown files}
{--no-extraction : Skip extraction of route and API info and just transform the YAML and Markdown files into HTML}
{--no-upgrade-check : Skip checking for config file upgrades. Won't make things faster, but can be helpful if the command is buggy}
{--config=scribe : Choose which config file to use}
{--scribe-dir= : Specify the directory where Scribe stores its intermediate output and cache. Defaults to `.<config_file>`}
";
protected $description = 'Generate API documentation from your Laravel routes.';
protected DocumentationConfig $docConfig;
protected bool $shouldExtract;
protected bool $forcing;
protected PathConfig $paths;
public function handle(RouteMatcherInterface $routeMatcher, GroupedEndpointsFactory $groupedEndpointsFactory): void
{
$this->bootstrap();
if (! empty($this->docConfig->get('default_group'))) {
$this->warn('It looks like you just upgraded to Scribe v4.');
$this->warn('Please run the upgrade command first: `php artisan scribe:upgrade`.');
exit(1);
}
// Extraction stage - extract endpoint info either from app or existing Camel files (previously extracted data)
$groupedEndpointsInstance = $groupedEndpointsFactory->make($this, $routeMatcher, $this->paths);
$extractedEndpoints = $groupedEndpointsInstance->get();
$userDefinedEndpoints = Camel::loadUserDefinedEndpoints(Camel::camelDir($this->paths));
$groupedEndpoints = $this->mergeUserDefinedEndpoints($extractedEndpoints, $userDefinedEndpoints);
// Output stage
$configFileOrder = $this->docConfig->get('groups.order', []);
$groupedEndpoints = Camel::prepareGroupedEndpointsForOutput($groupedEndpoints, $configFileOrder);
if (! count($userDefinedEndpoints)) {
// Update the example custom file if there were no custom endpoints
$this->writeExampleCustomEndpoint();
}
/** @var Writer $writer */
$writer = app(Writer::class, ['config' => $this->docConfig, 'paths' => $this->paths]);
$writer->writeDocs($groupedEndpoints);
// Retiring the automatic upgrade check, since the config file is no longer changing as frequently.
// $this->upgradeConfigFileIfNeeded();
$this->sayGoodbye(errored: $groupedEndpointsInstance->hasEncounteredErrors());
}
public function isForcing(): bool
{
return $this->forcing;
}
public function shouldExtract(): bool
{
return $this->shouldExtract;
}
public function getDocConfig(): DocumentationConfig
{
return $this->docConfig;
}
/**
* Get access to output components for task-based output.
*
* @return Factory
*/
public function outputComponents()
{
return $this->components;
}
public function bootstrap(): void
{
// The --verbose option is included with all Artisan commands.
Globals::$shouldBeVerbose = $this->option('verbose');
c::bootstrapOutput($this->output);
c::setCommand($this);
$configName = $this->option('config');
if (! config($configName)) {
throw new \InvalidArgumentException("The specified config (config/{$configName}.php) doesn't exist.");
}
$this->paths = new PathConfig($configName);
if ($this->hasOption('scribe-dir') && ! empty($this->option('scribe-dir'))) {
$this->paths = new PathConfig(
$configName,
scribeDir: $this->option('scribe-dir')
);
}
$this->docConfig = new DocumentationConfig(config($this->paths->configName));
// Force root URL so it works in Postman collection
$baseUrl = $this->docConfig->get('base_url') ?? config('app.url');
try {
// Renamed from forceRootUrl in Laravel 11.43 or so
URL::useOrigin($baseUrl);
} catch (\BadMethodCallException) {
URL::forceRootUrl($baseUrl);
}
$this->forcing = $this->option('force');
$this->shouldExtract = ! $this->option('no-extraction');
if ($this->forcing && ! $this->shouldExtract) {
throw new \InvalidArgumentException("Can't use --force and --no-extraction together.");
}
$this->runBootstrapHook();
}
protected function runBootstrapHook()
{
if (is_callable(Globals::$__bootstrap)) {
call_user_func_array(Globals::$__bootstrap, [$this]);
}
}
protected function mergeUserDefinedEndpoints(array $groupedEndpoints, array $userDefinedEndpoints): array
{
foreach ($userDefinedEndpoints as $endpoint) {
$indexOfGroupWhereThisEndpointShouldBeAdded = Arr::first(array_keys($groupedEndpoints), function ($key) use ($groupedEndpoints, $endpoint) {
$group = $groupedEndpoints[$key];
return $group['name'] === ($endpoint['metadata']['groupName'] ?? $this->docConfig->get('groups.default', ''));
});
if ($indexOfGroupWhereThisEndpointShouldBeAdded !== null) {
$groupedEndpoints[$indexOfGroupWhereThisEndpointShouldBeAdded]['endpoints'][] = $endpoint;
} else {
$newGroup = [
'name' => $endpoint['metadata']['groupName'] ?? $this->docConfig->get('groups.default', ''),
'description' => $endpoint['metadata']['groupDescription'] ?? null,
'endpoints' => [$endpoint],
];
$groupedEndpoints[$newGroup['name']] = $newGroup;
}
}
return $groupedEndpoints;
}
protected function writeExampleCustomEndpoint(): void
{
// We add an example to guide users in case they need to add a custom endpoint.
copy(__DIR__.'/../../resources/example_custom_endpoint.yaml', Camel::camelDir($this->paths).'/custom.0.yaml');
}
protected function upgradeConfigFileIfNeeded(): void
{
if ($this->option('no-upgrade-check')) {
return;
}
$this->info('Checking for any pending upgrades to your config file...');
try {
$defaultConfig = require __DIR__.'/../../config/scribe.php';
$ignore = ['example_languages', 'routes', 'description', 'auth.extra_info', 'intro_text', 'groups', 'database_connections_to_transact'];
$asList = ['strategies.*', 'examples.models_source'];
$differ = new ConfigDiffer(original: $this->docConfig->data, changed: $defaultConfig, ignorePaths: $ignore, asList: $asList);
$diff = $differ->getDiff();
// Remove items the user has set
$realDiff = [];
foreach ($diff as $key => $value) {
if (is_null($this->docConfig->get($key))) {
$realDiff[$key] = $value;
}
}
if (! empty($realDiff)) {
$this->newLine();
$this->warn("You're using an updated version of Scribe, which may have added new items to the config file.");
$this->info("Here's what is different:");
foreach ($realDiff as $key => $item) {
$this->line("{$key} --now defaults to-> {$item}");
}
if (! $this->input->isInteractive()) {
$this->info(sprintf('To upgrade, see the full changelog at: https://github.com/knuckleswtf/scribe/blob/%s/CHANGELOG.md,', Scribe::VERSION));
$this->info('And config reference at https://scribe.knuckles.wtf/laravel/reference/config');
return;
}
}
} catch (\Throwable $e) {
$this->warn('Check failed with error:');
e::dumpExceptionIfVerbose($e);
$this->warn('This did not affect your docs. Please report this issue in the project repo: https://github.com/knuckleswtf/scribe');
}
}
protected function sayGoodbye(bool $errored = false): void
{
$message = 'All done.';
$url = null;
if ($this->docConfig->outputRoutedThroughLaravel()) {
if ($this->docConfig->get('laravel.add_routes')) {
$url = url($this->docConfig->get('laravel.docs_url'));
}
} elseif (Str::endsWith(base_path('public'), 'public') && Str::startsWith($this->docConfig->get('static.output_path'), 'public/')) {
$url = url(str_replace('public/', '', $this->docConfig->get('static.output_path')));
}
$this->newLine();
if ($url) {
$this->components->twoColumnDetail($message, $url);
} else {
$this->components->info($message);
}
if ($errored) {
$this->components->warn('Generated docs, but encountered some errors while processing routes.');
$this->components->warn('Check the output above for details.');
if (empty($_SERVER['SCRIBE_TESTS'])) {
exit(2);
}
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Knuckles\Scribe\Commands;
use Illuminate\Console\GeneratorCommand;
class MakeStrategy extends GeneratorCommand
{
protected $signature = 'scribe:strategy
{name : Name of the class.}
{--force : Overwrite file if it exists}
';
protected $description = 'Create a new strategy class.';
protected $type = 'Strategy';
protected function getStub()
{
return __DIR__.'/stubs/strategy.stub';
}
protected function getDefaultNamespace($rootNamespace)
{
return $rootNamespace.'\Docs\Strategies';
}
protected function replaceClass($stub, $name)
{
return parent::replaceClass($stub, $name);
}
}

View File

@@ -0,0 +1,182 @@
<?php
namespace Knuckles\Scribe\Commands;
use Illuminate\Console\Command;
use Knuckles\Camel\Camel;
use Knuckles\Scribe\GroupedEndpoints\GroupedEndpointsFactory;
use Knuckles\Scribe\Scribe;
use Knuckles\Scribe\Tools\PathConfig;
use Shalvah\Upgrader\Upgrader;
use Symfony\Component\VarExporter\VarExporter;
class Upgrade extends Command
{
protected $signature = 'scribe:upgrade {--dry-run : Print the changes that will be made, without actually making them}
{--config=scribe : choose which config file to use}
';
protected $description = '';
protected bool $applyChanges;
protected string $configName;
public function handle(): void
{
$this->applyChanges = ! $this->option('dry-run');
$this->configName = $this->option('config');
if (! ($oldConfig = config($this->configName))) {
$this->error("The specified config (config/{$this->configName}.php) doesn't exist.");
return;
}
if (array_key_exists('interactive', $oldConfig)) {
$this->error("This upgrade tool is for upgrading from Scribe v3 to v4, but it looks like you're coming from v2.");
$this->error('Please install v3 and follow its upgrade guide first.');
return;
}
$isMajorUpgrade = array_key_exists('default_group', $oldConfig) || array_key_exists('faker_seed', $oldConfig);
if ($isMajorUpgrade) {
$this->info('Welcome to the Scribe v3 to v4 upgrader.');
}
$this->line('Checking for config file changes...');
$upgrader = Upgrader::ofConfigFile("config/{$this->configName}.php", __DIR__.'/../../config/scribe.php')
->dontTouch(
'routes',
'laravel.middleware',
'postman.overrides',
'openapi.overrides',
'example_languages',
'database_connections_to_transact',
'strategies',
'examples.models_source',
'external.html_attributes'
)
->move('default_group', 'groups.default')
->move('faker_seed', 'examples.faker_seed');
if (! $isMajorUpgrade) {
$upgrader->dontTouch('groups');
}
$changes = $upgrader->dryRun();
if (empty($changes)) {
$this->info('✔ No config file changes needed.');
} else {
$this->info('The following changes will be made to your config file:');
$this->newLine();
foreach ($changes as $change) {
$this->line($change['description']);
}
if ($this->applyChanges) {
$upgrader->upgrade();
$this->info("✔ Upgraded your config file. Your old config is backed up at config/{$this->configName}.php.bak.");
}
}
$this->newLine();
if (! $isMajorUpgrade) {
$this->info('✔ Done.');
$this->info(sprintf('See the full changelog at https://github.com/knuckleswtf/scribe/blob/%s/CHANGELOG.md', Scribe::VERSION));
return;
}
$this->finishV4Upgrade();
}
protected function finishV4Upgrade(): void
{
$this->migrateToConfigFileSort();
if ($this->applyChanges) {
if ($this->confirm('Do you have any custom strategies?')) {
$this->line('1. Add a new property <info>public ?ExtractedEndpointData $endpointData;</info>.');
$this->line('2. Replace the <info>array $routeRules</info> parameter in __invoke() with <info>array $routeRules = []</info> .');
}
$this->newLine();
$this->info('✔ Done.');
}
$this->line('See the release announcement at <href=https://scribe.knuckles.wtf/blog/laravel-v4>http://scribe.knuckles.wtf/blog/laravel-v4</> for the full upgrade guide!');
}
/**
* In v3, you sorted endpoints by reordering them in the group file, and groups by renaming the group files alphabetically
* (or by using `beforeGroup`/`afterGroup` for custom endpoints).
* v4 replaces them with the config item `groups.order`.
*/
protected function migrateToConfigFileSort()
{
$this->info('In v3, you sorted endpoints/groups by editing/renaming the generated YAML files (or `beforeGroup`/`afterGroup` for custom endpoints).');
$this->info("We'll automatically import your current sorting into the config item `groups.order`.");
$defaultGroup = config($this->configName.'.default_group');
$pathConfig = new PathConfig($this->configName);
$extractedEndpoints = GroupedEndpointsFactory::fromCamelDir($pathConfig)->get();
$order = array_map(function (array $group) {
return array_map(function (array $endpoint) {
return $endpoint['metadata']['title'] ?: ($endpoint['httpMethods'][0].' /'.$endpoint['uri']);
}, $group['endpoints']);
}, $extractedEndpoints);
$groupsOrder = array_keys($order);
$keyIndices = array_flip($groupsOrder);
$userDefinedEndpoints = Camel::loadUserDefinedEndpoints(Camel::camelDir($pathConfig));
if ($userDefinedEndpoints) {
foreach ($userDefinedEndpoints as $endpoint) {
$groupName = $endpoint['metadata']['groupName'] ?? $defaultGroup;
$endpointTitle = $endpoint['metadata']['title'] ?? ($endpoint['httpMethods'][0].' /'.$endpoint['uri']);
if (! isset($order[$groupName])) {
// This is a new group; place it at the right spot.
if ($nextGroup = $endpoint['metadata']['beforeGroup'] ?? null) {
$index = $keyIndices[$nextGroup];
array_splice($groupsOrder, $index, 0, [$groupName]);
} elseif ($previousGroup = $endpoint['metadata']['afterGroup'] ?? null) {
$index = $keyIndices[$previousGroup];
array_splice($groupsOrder, $index + 1, 0, [$groupName]);
} else {
$groupsOrder[] = $groupName;
}
$order[$groupName] = [$endpointTitle];
} else {
// Existing group, add endpoint
$order[$groupName] = [...$order[$groupName], $endpointTitle];
}
}
}
// Re-add them, so it's sorted in the right order
$newOrder = [];
foreach ($groupsOrder as $groupName) {
$newOrder[$groupName] = $order[$groupName];
}
$output = VarExporter::export($newOrder);
if ($this->applyChanges) {
$configFile = "config/{$this->configName}.php";
$output = str_replace("\n", "\n ", $output);
$newContents = str_replace(
"'order' => [],",
"'order' => {$output},",
file_get_contents($configFile)
);
file_put_contents($configFile, $newContents);
$this->info('✔ Updated `groups.order`.');
} else {
$this->line('- `groups.order` will be set to:');
$this->info($output);
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace DummyNamespace;
use Knuckles\Scribe\Extracting\ParamHelpers;
use Knuckles\Scribe\Extracting\Strategies\Strategy;
use Knuckles\Camel\Extraction\ExtractedEndpointData;
class DummyClass extends Strategy
{
/**
* Trait containing some helper methods for dealing with "parameters",
* such as generating examples and casting values to types.
* Useful if your strategy extracts information about parameters or generates examples.
*/
use ParamHelpers;
/**
* @link https://scribe.knuckles.wtf/laravel/advanced/plugins
* @param ExtractedEndpointData $endpointData The endpoint we are currently processing.
* Contains details about httpMethods, controller, method, route, url, etc, as well as already extracted data.
* @param array $settings Settings to be applied to this strategy
*
* See the documentation linked above for more details about writing custom strategies.
*
* @return array|null
*/
public function __invoke(ExtractedEndpointData $endpointData, array $settings = []): ?array
{
return [];
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Knuckles\Scribe\Config;
enum AuthIn: string
{
case BEARER = 'bearer';
case BASIC = 'basic';
case HEADER = 'header';
case QUERY = 'query';
case BODY = 'body';
case QUERY_OR_BODY = 'query_or_body';
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Knuckles\Scribe\Config;
use Knuckles\Scribe\Extracting\Strategies;
class Defaults
{
public const METADATA_STRATEGIES = [
Strategies\Metadata\GetFromDocBlocks::class,
Strategies\Metadata\GetFromMetadataAttributes::class,
];
public const HEADERS_STRATEGIES = [
Strategies\Headers\GetFromHeaderAttribute::class,
Strategies\Headers\GetFromHeaderTag::class,
];
public const URL_PARAMETERS_STRATEGIES = [
Strategies\UrlParameters\GetFromLaravelAPI::class,
Strategies\UrlParameters\GetFromUrlParamAttribute::class,
Strategies\UrlParameters\GetFromUrlParamTag::class,
];
public const QUERY_PARAMETERS_STRATEGIES = [
Strategies\QueryParameters\GetFromFormRequest::class,
Strategies\QueryParameters\GetFromInlineValidator::class,
Strategies\QueryParameters\GetFromQueryParamAttribute::class,
Strategies\QueryParameters\GetFromQueryParamTag::class,
];
public const BODY_PARAMETERS_STRATEGIES = [
Strategies\BodyParameters\GetFromFormRequest::class,
Strategies\BodyParameters\GetFromInlineValidator::class,
Strategies\BodyParameters\GetFromBodyParamAttribute::class,
Strategies\BodyParameters\GetFromBodyParamTag::class,
];
public const RESPONSES_STRATEGIES = [
Strategies\Responses\UseResponseAttributes::class,
Strategies\Responses\UseTransformerTags::class,
Strategies\Responses\UseApiResourceTags::class,
Strategies\Responses\UseResponseTag::class,
Strategies\Responses\UseResponseFileTag::class,
Strategies\Responses\ResponseCalls::class,
];
public const RESPONSE_FIELDS_STRATEGIES = [
Strategies\ResponseFields\GetFromResponseFieldAttribute::class,
Strategies\ResponseFields\GetFromResponseFieldTag::class,
];
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Knuckles\Scribe\Config;
use Illuminate\Support\Arr;
// Strategies can be:
// 1. (Original) A class name, e.g. Strategies\Responses\ResponseCalls::class
// 2. (New) A tuple containing the class name (or "static_data") as item 1, and its settings array as item 2
/**
* Remove one or more strategies from a list of strategies.
*/
function removeStrategies(array $strategiesList, array $strategyNamesToRemove): array
{
$correspondingStrategies = Arr::where($strategiesList, function ($strategy) use ($strategyNamesToRemove) {
$strategyName = is_string($strategy) ? $strategy : $strategy[0];
return in_array($strategyName, $strategyNamesToRemove);
});
foreach ($correspondingStrategies as $key => $value) {
unset($strategiesList[$key]);
}
return $strategiesList;
}
/**
* Add/replace a strategy and its settings in a list of strategies.
* This method generates a tuple containing [strategyName, settingsArray],
* and adds or replaces the strategy entry in the list.
*
* @param array $configurationTuple Tuple of [strategyName, settingsArray].
* By default, all strategies support the "only" and "except" setting to apply them to specific endpoints.
* You can easily create the tuple by calling Strategy::wrapWithSettings(only: [], except: []).
*/
function configureStrategy(array $strategiesList, array $configurationTuple): array
{
$strategyFound = false;
$strategiesList = array_map(function ($strategy) use ($configurationTuple, &$strategyFound) {
$strategyName = is_string($strategy) ? $strategy : $strategy[0];
if ($strategyName === $configurationTuple[0]) {
$strategyFound = true;
return $configurationTuple;
}
return $strategy;
}, $strategiesList);
// If strategy wasn't in there, add it.
if (! $strategyFound) {
$strategiesList = array_merge($strategiesList, [$configurationTuple]);
}
return $strategiesList;
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Knuckles\Scribe\Exceptions;
class CouldntFindFactory extends \RuntimeException implements ScribeException
{
public static function forModel(string $modelName): self
{
return new self("Couldn't find the Eloquent model factory. Did you add the HasFactory trait to your {$modelName} model?");
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Knuckles\Scribe\Exceptions;
class CouldntGetRouteDetails extends \RuntimeException implements ScribeException
{
public static function new(): self
{
return new self('Unable to retrieve controller and method for route; try running `php artisan route:clear`');
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Knuckles\Scribe\Exceptions;
class CouldntProcessValidationRule extends \RuntimeException implements ScribeException
{
public static function forParam(string $paramName, $rule, \Throwable $innerException): self
{
return new self(
"Couldn't process the validation rule ".var_export($rule, true)." for the param `{$paramName}`: {$innerException->getMessage()}",
0,
$innerException
);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Knuckles\Scribe\Exceptions;
class CouldntStartDatabaseTransaction extends \RuntimeException implements ScribeException
{
public static function forConnection(string $connectionName, \Throwable $originalException): self
{
return new self(
"Couldn't start a database transaction for the connection {$connectionName}. "
."Make sure this database is running. If you aren't using this connection, remove it from your `databaseConnectionsToTransact` config",
0,
$originalException
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Knuckles\Scribe\Exceptions;
class DatabaseTransactionsNotSupported extends \RuntimeException implements ScribeException
{
public static function create(string $connectionName, string $driverName)
{
return new self(
"Database driver [{$driverName}] for connection [{$connectionName}] does not support transactions."
." To allow Scribe to proceed, remove \"{$connectionName}\" from the \"database_connections_to_transact\" config array."
.' Note that any changes to your database will be persisted.'
);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Knuckles\Scribe\Exceptions;
class GroupNotFound extends \RuntimeException implements ScribeException
{
public static function forTag(string $groupName, string $tag)
{
return new self(
<<<MESSAGE
You specified the group "{$groupName}" in a "{$tag}" field in one of your custom endpoints, but we couldn't find that group.
Did you rename the group?
MESSAGE
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Knuckles\Scribe\Exceptions;
class ProblemParsingValidationRules extends \RuntimeException implements ScribeException
{
public static function forParam(string $paramName, \Throwable $innerException): self
{
return new self(
"Problem processing validation rules for the param `{$paramName}`: {$innerException->getMessage()}",
0,
$innerException
);
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Knuckles\Scribe\Exceptions;
/**
* Scribe Exceptions are thrown intentionally by us, and should not be swallowed.
* They are meant to crash the task and be thrown back to the user.
*/
interface ScribeException {}

View File

@@ -0,0 +1,169 @@
<?php
namespace Knuckles\Scribe\Extracting;
use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
use Knuckles\Scribe\Tools\DocumentationConfig;
use Knuckles\Scribe\Tools\PathConfig;
use Knuckles\Scribe\Tools\Utils as u;
/**
* Handles extracting other API details intro, auth.
*/
class ApiDetails
{
private DocumentationConfig $config;
private string $baseUrl;
private bool $preserveUserChanges;
private string $markdownOutputPath;
private string $fileHashesTrackingFile;
private array $lastKnownFileContentHashes = [];
public function __construct(
PathConfig $paths,
?DocumentationConfig $config = null,
bool $preserveUserChanges = true,
) {
$this->markdownOutputPath = $paths->intermediateOutputPath(); // .scribe by default
// If no config is injected, pull from global. Makes testing easier.
$this->config = $config ?: new DocumentationConfig(config($paths->configName));
$this->baseUrl = $this->config->get('base_url') ?? config('app.url');
$this->preserveUserChanges = $preserveUserChanges;
$this->fileHashesTrackingFile = $this->markdownOutputPath.'/.filehashes';
$this->lastKnownFileContentHashes = [];
}
public function writeMarkdownFiles(): void
{
c::task(
'Extracting intro and auth Markdown files to: '.$this->markdownOutputPath,
function () {
if (! is_dir($this->markdownOutputPath)) {
mkdir($this->markdownOutputPath, 0o777, true);
}
$this->fetchFileHashesFromTrackingFile();
$this->writeIntroMarkdownFile();
$this->writeAuthMarkdownFile();
$this->writeContentsTrackingFile();
return true;
}
);
}
public function writeIntroMarkdownFile(): void
{
$introMarkdownFile = $this->markdownOutputPath.'/intro.md';
if ($this->hasFileBeenModified($introMarkdownFile)) {
if ($this->preserveUserChanges) {
c::warn("Skipping modified file {$introMarkdownFile}");
return;
}
c::warn("Discarding manual changes for file {$introMarkdownFile} because you specified --force");
}
$introMarkdown = view('scribe::markdown.intro')
->with('description', $this->config->get('description', ''))
->with('introText', $this->config->get('intro_text', ''))
->with('baseUrl', $this->baseUrl)->render();
$this->writeMarkdownFileAndRecordHash($introMarkdownFile, $introMarkdown);
}
public function writeAuthMarkdownFile(): void
{
$authMarkdownFile = $this->markdownOutputPath.'/auth.md';
if ($this->hasFileBeenModified($authMarkdownFile)) {
if ($this->preserveUserChanges) {
c::warn("Skipping modified file {$authMarkdownFile}");
return;
}
c::warn("Discarding manual changes for file {$authMarkdownFile} because you specified --force");
}
$isAuthed = $this->config->get('auth.enabled', false);
$authDescription = '';
$extraInfo = '';
if ($isAuthed) {
$strategy = $this->config->get('auth.in');
$parameterName = $this->config->get('auth.name');
$authDescription = u::trans(
"scribe::auth.instruction.{$strategy}",
[
'parameterName' => $parameterName,
'placeholder' => $this->config->get('auth.placeholder') ?: 'your-token']
);
$authDescription .= "\n\n".u::trans('scribe::auth.details');
$extraInfo = $this->config->get('auth.extra_info', '');
}
$authMarkdown = view('scribe::markdown.auth', [
'isAuthed' => $isAuthed,
'authDescription' => $authDescription,
'extraAuthInfo' => $extraInfo,
])->render();
$this->writeMarkdownFileAndRecordHash($authMarkdownFile, $authMarkdown);
}
protected function writeMarkdownFileAndRecordHash(string $filePath, string $markdown): void
{
file_put_contents($filePath, $markdown);
$this->lastKnownFileContentHashes[$filePath] = hash_file('md5', $filePath);
}
protected function writeContentsTrackingFile(): void
{
$content = "# GENERATED. YOU SHOULDN'T MODIFY OR DELETE THIS FILE.\n";
$content .= "# Scribe uses this file to know when you change something manually in your docs.\n";
$content .= collect($this->lastKnownFileContentHashes)
->map(fn ($hash, $filePath) => "{$filePath}={$hash}")->implode("\n");
file_put_contents($this->fileHashesTrackingFile, $content);
}
protected function hasFileBeenModified(string $filePath): bool
{
if (! file_exists($filePath)) {
return false;
}
$oldFileHash = $this->lastKnownFileContentHashes[$filePath] ?? null;
if ($oldFileHash) {
$currentFileHash = hash_file('md5', $filePath);
// No danger of a timing attack, so no need for hash_equals() comparison
return $currentFileHash !== $oldFileHash;
}
return false;
}
protected function fetchFileHashesFromTrackingFile()
{
if (file_exists($this->fileHashesTrackingFile)) {
$lastKnownFileHashes = explode("\n", mb_trim(file_get_contents($this->fileHashesTrackingFile)));
// First two lines are comments
array_shift($lastKnownFileHashes);
array_shift($lastKnownFileHashes);
$this->lastKnownFileContentHashes = collect($lastKnownFileHashes)
->mapWithKeys(function ($line) {
[$filePath, $hash] = explode('=', $line);
return [$filePath => $hash];
})->toArray();
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Knuckles\Scribe\Extracting;
use Knuckles\Scribe\Exceptions\CouldntStartDatabaseTransaction;
use Knuckles\Scribe\Exceptions\DatabaseTransactionsNotSupported;
use Knuckles\Scribe\Tools\DocumentationConfig;
trait DatabaseTransactionHelpers
{
/**
* Returns an instance of the documentation config.
*/
abstract public function getConfig(): DocumentationConfig;
private function connectionsToTransact()
{
return $this->getConfig()->get('database_connections_to_transact', []);
}
private function startDbTransaction()
{
foreach ($this->connectionsToTransact() as $connection) {
$database ??= app('db');
$driver = $database->connection($connection);
if (self::driverSupportsTransactions($driver)) {
try {
$driver->beginTransaction();
} catch (\Throwable $e) {
throw CouldntStartDatabaseTransaction::forConnection($connection, $e);
}
} else {
$driverClassName = get_class($driver);
throw DatabaseTransactionsNotSupported::create($connection, $driverClassName);
}
}
}
private function endDbTransaction()
{
foreach ($this->connectionsToTransact() as $connection) {
$database ??= app('db');
$driver = $database->connection($connection);
try {
$driver->rollback();
} catch (\Exception $e) {
// Any error handling should have been done on the startDbTransaction() side
}
}
}
private static function driverSupportsTransactions($driver): bool
{
$methods = ['beginTransaction', 'rollback'];
foreach ($methods as $method) {
if (! method_exists($driver, $method)) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,520 @@
<?php
namespace Knuckles\Scribe\Extracting;
use Faker\Factory;
use Illuminate\Http\Testing\File;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Knuckles\Camel\Extraction\ExtractedEndpointData;
use Knuckles\Camel\Extraction\Metadata;
use Knuckles\Camel\Extraction\Parameter;
use Knuckles\Camel\Extraction\ResponseCollection;
use Knuckles\Camel\Extraction\ResponseField;
use Knuckles\Camel\Output\OutputEndpointData;
use Knuckles\Scribe\Extracting\Strategies\StaticData;
use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
use Knuckles\Scribe\Tools\DocumentationConfig;
use Knuckles\Scribe\Tools\RoutePatternMatcher;
class Extractor
{
use ParamHelpers;
private DocumentationConfig $config;
private static ?Route $routeBeingProcessed = null;
public function __construct(?DocumentationConfig $config = null)
{
// If no config is injected, pull from global
$this->config = $config ?: new DocumentationConfig(config('scribe'));
}
/**
* External interface that allows users to know what route is currently being processed.
*/
public static function getRouteBeingProcessed(): ?Route
{
return self::$routeBeingProcessed;
}
/**
* @param array $routeRules Rules to apply when generating documentation for this route. Deprecated. Use strategy config instead.
*/
public function processRoute(Route $route, array $routeRules = []): ExtractedEndpointData
{
self::$routeBeingProcessed = $route;
$endpointData = ExtractedEndpointData::fromRoute($route);
$inheritedDocsOverrides = [];
if ($endpointData->controller->hasMethod('inheritedDocsOverrides')) {
$inheritedDocsOverrides = call_user_func([$endpointData->controller->getName(), 'inheritedDocsOverrides']);
$inheritedDocsOverrides = $inheritedDocsOverrides[$endpointData->method->getName()] ?? [];
}
$this->fetchMetadata($endpointData, $routeRules);
$this->mergeInheritedMethodsData('metadata', $endpointData, $inheritedDocsOverrides);
$this->fetchUrlParameters($endpointData, $routeRules);
$this->mergeInheritedMethodsData('urlParameters', $endpointData, $inheritedDocsOverrides);
$endpointData->cleanUrlParameters = self::cleanParams($endpointData->urlParameters);
$this->addAuthField($endpointData);
$this->fetchQueryParameters($endpointData, $routeRules);
$this->mergeInheritedMethodsData('queryParameters', $endpointData, $inheritedDocsOverrides);
$endpointData->cleanQueryParameters = self::cleanParams($endpointData->queryParameters);
$this->fetchRequestHeaders($endpointData, $routeRules);
$this->mergeInheritedMethodsData('headers', $endpointData, $inheritedDocsOverrides);
$this->fetchBodyParameters($endpointData, $routeRules);
$endpointData->cleanBodyParameters = self::cleanParams($endpointData->bodyParameters);
$this->mergeInheritedMethodsData('bodyParameters', $endpointData, $inheritedDocsOverrides);
if (count($endpointData->cleanBodyParameters) && ! isset($endpointData->headers['Content-Type'])) {
// Set content type if the user forgot to set it
$endpointData->headers['Content-Type'] = 'application/json';
}
// We need to do all this so response calls can work correctly,
// even though they're only needed for output
// Note that this
[$files, $regularParameters] = OutputEndpointData::splitIntoFileAndRegularParameters($endpointData->cleanBodyParameters);
if (count($files)) {
$endpointData->headers['Content-Type'] = 'multipart/form-data';
}
$endpointData->fileParameters = $files;
$endpointData->cleanBodyParameters = $regularParameters;
$this->fetchResponses($endpointData, $routeRules);
$this->mergeInheritedMethodsData('responses', $endpointData, $inheritedDocsOverrides);
$this->fetchResponseFields($endpointData, $routeRules);
$this->mergeInheritedMethodsData('responseFields', $endpointData, $inheritedDocsOverrides);
self::$routeBeingProcessed = null;
return $endpointData;
}
public function shouldSkipRoute($route, array $routesToExclude, array $routesToInclude): bool
{
if (! empty($routesToExclude) && RoutePatternMatcher::matches($route, $routesToExclude)) {
return true;
}
if (! empty($routesToInclude) && ! RoutePatternMatcher::matches($route, $routesToInclude)) {
return true;
}
return false;
}
/**
* This method prepares and simplifies request parameters for use in example requests and response calls.
* It takes in an array with rich details about a parameter eg
* ['age' => new Parameter([
* 'description' => 'The age',
* 'example' => 12,
* 'required' => false,
* ])]
* And transforms them into key-example pairs : ['age' => 12]
* It also filters out parameters which have null values and have 'required' as false.
* It converts all file params that have string examples to actual files (instances of UploadedFile).
*
* @param array<string,Parameter> $parameters
*/
public static function cleanParams(array $parameters): array
{
$cleanParameters = [];
/**
* @var string $paramName
* @var Parameter $details
*/
foreach ($parameters as $paramName => $details) {
// Remove params which have no intentional examples and are optional.
if (! $details->exampleWasSpecified) {
if (is_null($details->example) && $details->required === false) {
continue;
}
}
if ($details->type === 'file') {
if (is_string($details->example)) {
$details->example = self::convertStringValueToUploadedFileInstance($details->example);
} elseif (is_null($details->example)) {
$details->example = (new self)->generateDummyValue($details->type);
}
}
if (Str::startsWith($paramName, '[].')) { // Entire body is an array
if (empty($parameters['[]'])) { // Make sure there's a parent
$cleanParameters['[]'] = [[], []];
$parameters['[]'] = new Parameter([
'name' => '[]',
'type' => 'object[]',
'description' => '',
'required' => true,
'example' => [$paramName => $details->example],
]);
}
}
if (Str::contains($paramName, '.')) { // Object field (or array of objects)
self::setObject($cleanParameters, $paramName, $details->example, $parameters, $details->required);
} else {
$cleanParameters[$paramName] = $details->example;
}
}
// Finally, if the body is an array, flatten it.
if (isset($cleanParameters['[]'])) {
$cleanParameters = $cleanParameters['[]'];
}
return $cleanParameters;
}
public static function setObject(array &$results, string $path, $value, array $source, bool $isRequired)
{
$parts = explode('.', $path);
array_pop($parts); // Get rid of the field name
$baseName = implode('.', $parts);
// For array fields, the type should be indicated in the source object by now;
// eg test.items[] would actually be described as name: test.items, type: object[]
// So we get rid of that ending []
// For other fields (eg test.items[].name), it remains as-is
$baseNameInOriginalParams = $baseName;
while (Str::endsWith($baseNameInOriginalParams, '[]')) {
$baseNameInOriginalParams = mb_substr($baseNameInOriginalParams, 0, -2);
}
// When the body is an array, param names will be "[].paramname",
// so $baseNameInOriginalParams here will be empty
if (Str::startsWith($path, '[].')) {
$baseNameInOriginalParams = '[]';
}
if (Arr::has($source, $baseNameInOriginalParams)) {
/** @var Parameter $parentData */
$parentData = Arr::get($source, $baseNameInOriginalParams);
// Path we use for data_set
$dotPath = str_replace('[]', '.0', $path);
// Don't overwrite parent if there's already data there
if ($parentData->type === 'object') {
$parentPath = explode('.', $dotPath);
$property = array_pop($parentPath);
$parentPath = implode('.', $parentPath);
$exampleFromParent = Arr::get($results, $dotPath) ?? $parentData->example[$property] ?? null;
if (empty($exampleFromParent)) {
Arr::set($results, $dotPath, $value);
}
} elseif ($parentData->type === 'object[]') {
// When the body is an array, param names will be "[].paramname", so dot paths won't work correctly with "[]"
if (Str::startsWith($path, '[].')) {
$valueDotPath = mb_substr($dotPath, 3); // Remove initial '.0.'
if (isset($results['[]'][0]) && ! Arr::has($results['[]'][0], $valueDotPath)) {
Arr::set($results['[]'][0], $valueDotPath, $value);
}
} else {
$parentPath = explode('.', $dotPath);
$index = (int) array_pop($parentPath);
$parentPath = implode('.', $parentPath);
$exampleFromParent = Arr::get($results, $dotPath) ?? $parentData->example[$index] ?? null;
if (empty($exampleFromParent)) {
Arr::set($results, $dotPath, $value);
}
}
}
}
}
public function addAuthField(ExtractedEndpointData $endpointData): void
{
$isApiAuthed = $this->config->get('auth.enabled', false);
if (! $isApiAuthed || ! $endpointData->metadata->authenticated) {
return;
}
$strategy = $this->config->get('auth.in');
$parameterName = $this->config->get('auth.name');
$faker = Factory::create();
if ($seed = $this->config->get('examples.faker_seed')) {
$faker->seed($seed);
}
$token = $faker->shuffleString('abcdefghkvaZVDPE1864563');
$valueToUse = $this->config->get('auth.use_value');
$valueToDisplay = $this->config->get('auth.placeholder');
switch ($strategy) {
case 'query':
case 'query_or_body':
$endpointData->auth = ['queryParameters', $parameterName, $valueToUse ?: $token];
$endpointData->queryParameters[$parameterName] = new Parameter([
'name' => $parameterName,
'type' => 'string',
'example' => $valueToDisplay ?: $token,
'description' => 'Authentication key.',
'required' => true,
]);
return;
case 'body':
$endpointData->auth = ['bodyParameters', $parameterName, $valueToUse ?: $token];
$endpointData->bodyParameters[$parameterName] = new Parameter([
'name' => $parameterName,
'type' => 'string',
'example' => $valueToDisplay ?: $token,
'description' => 'Authentication key.',
'required' => true,
]);
return;
case 'bearer':
$endpointData->auth = ['headers', 'Authorization', 'Bearer '.($valueToUse ?: $token)];
$endpointData->headers['Authorization'] = 'Bearer '.($valueToDisplay ?: $token);
return;
case 'basic':
$endpointData->auth = ['headers', 'Authorization', 'Basic '.($valueToUse ?: base64_encode($token))];
$endpointData->headers['Authorization'] = 'Basic '.($valueToDisplay ?: base64_encode($token));
return;
case 'header':
$endpointData->auth = ['headers', $parameterName, $valueToUse ?: $token];
$endpointData->headers[$parameterName] = $valueToDisplay ?: $token;
return;
}
}
public static function transformOldRouteRulesIntoNewSettings($stage, $rulesToApply, $strategyName, $strategySettings = [])
{
if ($stage === 'responses' && Str::is('*ResponseCalls*', $strategyName) && ! empty($rulesToApply['response_calls'])) {
// Transform `methods` to `only`
$strategySettings = [];
if (isset($rulesToApply['response_calls']['methods'])) {
if (empty($rulesToApply['response_calls']['methods'])) {
// This means all routes are forbidden
$strategySettings['except'] = ['*'];
} else {
$strategySettings['only'] = array_map(
fn ($method) => "{$method} *",
$rulesToApply['response_calls']['methods']
);
}
}
// Copy all other keys over as is
foreach (array_keys($rulesToApply['response_calls']) as $key) {
if ($key === 'methods') {
continue;
}
$strategySettings[$key] = $rulesToApply['response_calls'][$key];
}
}
return $strategySettings;
}
protected function fetchMetadata(ExtractedEndpointData $endpointData, array $rulesToApply): void
{
$endpointData->metadata = new Metadata([
'groupName' => $this->config->get('groups.default', ''),
'authenticated' => $this->config->get('auth.default', false),
]);
$this->iterateThroughStrategies('metadata', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
foreach ($results as $key => $item) {
$hadPreviousValue = ! is_null($endpointData->metadata->{$key});
$noNewValueSet = is_null($item) || $item === '';
if ($hadPreviousValue && $noNewValueSet) {
continue;
}
$endpointData->metadata->{$key} = $item;
}
});
}
protected function fetchUrlParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
{
$this->iterateThroughStrategies('urlParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
foreach ($results as $key => $item) {
if (empty($item['name'])) {
$item['name'] = $key;
}
$endpointData->urlParameters[$key] = Parameter::create($item, $endpointData->urlParameters[$key] ?? []);
}
});
}
protected function fetchQueryParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
{
$this->iterateThroughStrategies('queryParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
foreach ($results as $key => $item) {
if (empty($item['name'])) {
$item['name'] = $key;
}
$endpointData->queryParameters[$key] = Parameter::create($item, $endpointData->queryParameters[$key] ?? []);
}
});
}
protected function fetchBodyParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
{
$this->iterateThroughStrategies('bodyParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
foreach ($results as $key => $item) {
if (empty($item['name'])) {
$item['name'] = $key;
}
$endpointData->bodyParameters[$key] = Parameter::create($item, $endpointData->bodyParameters[$key] ?? []);
}
});
}
protected function fetchResponses(ExtractedEndpointData $endpointData, array $rulesToApply): void
{
$this->iterateThroughStrategies('responses', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
// Responses from different strategies are all added, not overwritten
$endpointData->responses->concat($results);
});
// Ensure 200 responses come first
$endpointData->responses = new ResponseCollection($endpointData->responses->sortBy('status')->values());
}
protected function fetchResponseFields(ExtractedEndpointData $endpointData, array $rulesToApply): void
{
$this->iterateThroughStrategies('responseFields', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
foreach ($results as $key => $item) {
$endpointData->responseFields[$key] = Parameter::create($item, $endpointData->responseFields[$key] ?? []);
}
});
}
protected function fetchRequestHeaders(ExtractedEndpointData $endpointData, array $rulesToApply): void
{
$this->iterateThroughStrategies('headers', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
foreach ($results as $key => $item) {
if ($item) {
$endpointData->headers[$key] = $item;
}
}
});
}
/**
* Iterate through all defined strategies for this stage.
* A strategy may return an array of attributes
* to be added to that stage data, or it may modify the stage data directly.
*
* @param array $rulesToApply Deprecated. Use strategy config instead.
* @param callable $handler function to run after each strategy returns its results (an array)
*/
protected function iterateThroughStrategies(
string $stage,
ExtractedEndpointData $endpointData,
array $rulesToApply,
callable $handler,
): void {
$strategies = $this->config->get("strategies.{$stage}", []);
foreach ($strategies as $strategyClassOrTuple) {
if (is_array($strategyClassOrTuple)) {
[$strategyClass, &$settings] = $strategyClassOrTuple;
if ($strategyClass === 'override') {
c::warn("The 'override' strategy was renamed to 'static_data', and will stop working in the future. Please replace 'override' in your config file with 'static_data'.");
$strategyClass = 'static_data';
}
if ($strategyClass === 'static_data') {
$strategyClass = StaticData::class;
// Static data can be short: ['static_data', ['key' => 'value']],
// or extended ['static_data', ['data' => ['key' => 'value'], 'only' => ['GET *'], 'except' => []]],
$settingsFormat = array_key_exists('data', $settings) ? 'extended' : 'short';
$settings = match ($settingsFormat) {
'extended' => $settings,
'short' => ['data' => $settings],
};
}
} else {
$strategyClass = $strategyClassOrTuple;
$settings = [];
}
$routesToExclude = Arr::wrap($settings['except'] ?? []);
$routesToInclude = Arr::wrap($settings['only'] ?? []);
if ($this->shouldSkipRoute($endpointData->route, $routesToExclude, $routesToInclude)) {
continue;
}
$strategy = new $strategyClass($this->config);
$results = $strategy($endpointData, $settings);
if (is_array($results)) {
$handler($results);
}
}
}
protected static function convertStringValueToUploadedFileInstance(string $filePath): UploadedFile
{
$fileName = basename($filePath);
return new File($fileName, fopen($filePath, 'r'));
}
protected function mergeInheritedMethodsData(string $stage, ExtractedEndpointData $endpointData, array $inheritedDocsOverrides = []): void
{
$overrides = $inheritedDocsOverrides[$stage] ?? [];
$normalizeParamData = fn ($data, $key) => array_merge($data, ['name' => $key]);
if (is_array($overrides)) {
foreach ($overrides as $key => $item) {
switch ($stage) {
case 'responses':
$endpointData->responses->concat($overrides);
$endpointData->responses->sortBy('status');
break;
case 'urlParameters':
case 'bodyParameters':
case 'queryParameters':
$endpointData->{$stage}[$key] = Parameter::make($normalizeParamData($item, $key));
break;
case 'responseFields':
$endpointData->{$stage}[$key] = ResponseField::make($normalizeParamData($item, $key));
break;
default:
$endpointData->{$stage}[$key] = $item;
}
}
} elseif (is_callable($overrides)) {
$results = $overrides($endpointData);
$endpointData->{$stage} = match ($stage) {
'responses' => ResponseCollection::make($results),
'urlParameters', 'bodyParameters', 'queryParameters' => collect($results)->map(fn ($param, $name) => Parameter::make($normalizeParamData($param, $name)))->all(),
'responseFields' => collect($results)->map(fn ($field, $name) => ResponseField::make($normalizeParamData($field, $name)))->all(),
default => $results,
};
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Knuckles\Scribe\Extracting;
use Illuminate\Foundation\Http\FormRequest;
trait FindsFormRequestForMethod
{
protected function getFormRequestReflectionClass(\ReflectionFunctionAbstract $method): ?\ReflectionClass
{
foreach ($method->getParameters() as $argument) {
$argType = $argument->getType();
if ($argType === null || $argType instanceof \ReflectionUnionType) {
continue;
}
$argumentClassName = $argType->getName();
if (! class_exists($argumentClassName)) {
continue;
}
try {
$argumentClass = new \ReflectionClass($argumentClassName);
} catch (\ReflectionException $e) {
continue;
}
if ($argumentClass->isSubclassOf(FormRequest::class)) {
return $argumentClass;
}
}
return null;
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace Knuckles\Scribe\Extracting;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
use Knuckles\Scribe\Tools\ErrorHandlingUtils as e;
use Knuckles\Scribe\Tools\Utils;
trait InstantiatesExampleModels
{
/**
* @param string[] $factoryStates
* @param string[] $relations
* @param null|\ReflectionFunctionAbstract $transformationMethod A method which has the model as its first parameter. Useful if the `$type` is empty.
* @return null|Model|object
*/
protected function instantiateExampleModel(
?string $type = null,
array $factoryStates = [],
array $relations = [],
?\ReflectionFunctionAbstract $transformationMethod = null,
array $withCount = [],
) {
// If the API Resource uses an empty resource, there won't be an example model
if ($type === null && $transformationMethod === null) {
return null;
}
if ($type === null) {
$parameter = Arr::first($transformationMethod->getParameters());
$parameterType = $parameter->hasType() ? $parameter->getType() : null;
if ($parameterType instanceof \ReflectionNamedType
&& ! $parameterType->isBuiltin() && class_exists($parameterType->getName())) {
// Ladies and gentlemen, we have a type!
$type = $parameterType->getName();
}
}
if ($type === null) {
throw new \Exception("Couldn't detect a transformer model from your doc block. Did you remember to specify a model using @transformerModel?");
}
$configuredStrategies = $this->config->get('examples.models_source', ['factoryCreate', 'factoryMake', 'databaseFirst']);
$strategies = [
'factoryCreate' => fn () => $this->getExampleModelFromFactoryCreate($type, $factoryStates, $relations, $withCount),
'factoryCreateQuietly' => fn () => $this->getExampleModelFromFactoryCreate($type, $factoryStates, $relations, $withCount, true),
'factoryMake' => fn () => $this->getExampleModelFromFactoryMake($type, $factoryStates, $relations),
'databaseFirst' => fn () => $this->getExampleModelFromDatabaseFirst($type, $relations),
];
foreach ($configuredStrategies as $strategyName) {
try {
$model = $strategies[$strategyName]();
if ($model) {
return $model;
}
} catch (\Throwable $e) {
c::warn("Couldn't get example model for {$type} via {$strategyName}.");
e::dumpExceptionIfVerbose($e);
}
}
return new $type;
}
/**
* @param class-string $type
* @param string[] $factoryStates
* @param string[] $relations
* @param string[] $withCount
* @return null|Model
*/
protected function getExampleModelFromFactoryCreate(string $type, array $factoryStates = [], array $relations = [], array $withCount = [], bool $quietly = false)
{
// Since $relations and $withCount refer to the same underlying relationships in the model,
// combining them ensures that all required relationships are initialized when passed to the factory.
$allRelations = array_unique(array_merge($relations, $withCount));
$factory = Utils::getModelFactory($type, $factoryStates, $allRelations);
$factory = $quietly ? Model::withoutEvents(fn () => $factory->create()) : $factory->create();
return $factory->refresh()->load($relations)->loadCount($withCount);
}
/**
* @param class-string $type
* @param string[] $factoryStates
* @return null|Model
*/
protected function getExampleModelFromFactoryMake(string $type, array $factoryStates = [], array $relations = [])
{
$factory = Utils::getModelFactory($type, $factoryStates, $relations);
return $factory->make();
}
/**
* @param class-string $type
* @param string[] $relations
* @return null|Model
*/
protected function getExampleModelFromDatabaseFirst(string $type, array $relations = [])
{
return $type::with($relations)->first();
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace Knuckles\Scribe\Extracting;
use PhpParser\Node;
use PhpParser\Node\Stmt;
use PhpParser\NodeFinder;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\ParserFactory;
/**
* MethodAstParser
* Utility class to help with retrieving (and caching) ASTs of route methods.
*/
class MethodAstParser
{
protected static array $methodAsts = [];
protected static array $classAsts = [];
public static function getMethodAst(\ReflectionFunctionAbstract $method)
{
$methodName = $method->name;
$fileName = $method->getFileName();
$methodAst = self::getCachedMethodAst($fileName, $methodName);
if ($methodAst) {
return $methodAst;
}
$classAst = self::getClassAst($fileName);
$methodAst = self::findMethodInClassAst($classAst, $methodName);
self::cacheMethodAst($fileName, $methodName, $methodAst);
return $methodAst;
}
/**
* @return null|Stmt[]
*/
protected static function parseClassSourceCode(string $sourceCode): ?array
{
$parser = (new ParserFactory)->createForHostVersion();
try {
$ast = $parser->parse($sourceCode);
} catch (\Throwable $error) {
throw new \Exception("Parse error: {$error->getMessage()}");
}
$traverser = new NodeTraverser(new NameResolver(options: ['replaceNodes' => false]));
try {
$traverser->traverse($ast);
} catch (\Throwable $error) {
throw new \Exception("Traverse error: {$error->getMessage()}");
}
return $ast;
}
/**
* @param Stmt[] $ast
* @return null|Node
*/
protected static function findMethodInClassAst(array $ast, string $methodName)
{
$nodeFinder = new NodeFinder;
return $nodeFinder->findFirst($ast, function (Node $node) use ($methodName) {
// Todo handle closures
return $node instanceof Stmt\ClassMethod
&& $node->name->toString() === $methodName;
});
}
protected static function getCachedMethodAst(string $fileName, string $methodName)
{
$key = self::getAstCacheId($fileName, $methodName);
return self::$methodAsts[$key] ?? null;
}
protected static function cacheMethodAst(string $fileName, string $methodName, Node $methodAst)
{
$key = self::getAstCacheId($fileName, $methodName);
self::$methodAsts[$key] = $methodAst;
}
private static function getAstCacheId(string $fileName, string $methodName): string
{
return $fileName.'///'.$methodName;
}
private static function getClassAst(string $fileName)
{
$classAst = self::$classAsts[$fileName]
?? self::parseClassSourceCode(file_get_contents($fileName));
return self::$classAsts[$fileName] = $classAst;
}
}

View File

@@ -0,0 +1,270 @@
<?php
namespace Knuckles\Scribe\Extracting;
use Faker\Factory;
use Faker\Generator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
trait ParamHelpers
{
/**
* Normalizes the stated "type" of a parameter (eg "int", "integer", "double", "array"...)
* to a number of standard JSON types (integer, boolean, number, object...).
* Will return the input if no match.
*
* @param mixed $value
*/
public static function normalizeTypeName(?string $typeName, $value = null): string
{
if (! $typeName) {
return 'string';
}
$base = str_replace('[]', '', mb_strtolower($typeName));
return match ($base) {
'bool' => str_replace($base, 'boolean', $typeName),
'int' => str_replace($base, 'integer', $typeName),
'float', 'double' => str_replace($base, 'number', $typeName),
'array' => (empty($value) || array_keys($value)[0] === 0)
? static::normalizeTypeName(gettype($value[0] ?? '')).'[]'
: 'object',
default => $typeName
};
}
protected function getFakeFactoryByName(string $name): ?\Closure
{
$faker = $this->getFaker();
$name = mb_strtolower(array_reverse(explode('.', $name))[0]);
$normalizedName = match (true) {
Str::endsWith($name, ['email', 'email_address']) => 'email',
Str::endsWith($name, ['uuid']) => 'uuid',
Str::endsWith($name, ['url']) => 'url',
Str::endsWith($name, ['locale']) => 'locale',
Str::endsWith($name, ['timezone']) => 'timezone',
default => $name,
};
return match ($normalizedName) {
'email' => fn () => $faker->safeEmail(),
'password', 'pwd' => fn () => $faker->password(),
'url' => fn () => $faker->url(),
'description' => fn () => $faker->sentence(),
'uuid' => fn () => $faker->uuid(),
'locale' => fn () => $faker->locale(),
'timezone' => fn () => $faker->timezone(),
default => null,
};
}
protected function getFaker(): Generator
{
$faker = Factory::create();
if ($seed = $this->config->get('examples.faker_seed')) {
$faker->seed($seed);
}
return $faker;
}
protected function generateDummyValue(string $type, array $hints = [])
{
if (! empty($hints['enumValues'])) {
return Arr::random($hints['enumValues']);
}
$fakeFactory = $this->getDummyValueGenerator($type, $hints);
return $fakeFactory();
}
protected function getDummyValueGenerator(string $type, array $hints = []): \Closure
{
$baseType = $type;
$isListType = false;
if (Str::endsWith($type, '[]')) {
$baseType = mb_strtolower(mb_substr($type, 0, mb_strlen($type) - 2));
$isListType = true;
}
$size = $hints['size'] ?? null;
if ($isListType) {
// Return a one-array item for a list by default.
return $size
? fn () => [$this->generateDummyValue($baseType, range(0, min($size - 1, 5)))]
: fn () => [$this->generateDummyValue($baseType, $hints)];
}
if (($hints['name'] ?? false) && $baseType !== 'file') {
$fakeFactoryByName = $this->getFakeFactoryByName($hints['name']);
if ($fakeFactoryByName) {
return $fakeFactoryByName;
}
}
$faker = $this->getFaker();
$min = $hints['min'] ?? null;
$max = $hints['max'] ?? null;
// If max and min were provided, the override size.
$isExactSize = is_null($min) && is_null($max) && ! is_null($size);
$fakeFactoriesByType = [
'integer' => function () use ($size, $isExactSize, $max, $faker, $min) {
if ($isExactSize) {
return $size;
}
return $max ? $faker->numberBetween((int) $min, (int) $max) : $faker->numberBetween(1, 20);
},
'number' => function () use ($size, $isExactSize, $max, $faker, $min) {
if ($isExactSize) {
return $size;
}
return $max ? $faker->numberBetween((int) $min, (int) $max) : $faker->randomFloat();
},
'boolean' => fn () => $faker->boolean(),
'string' => fn () => $size ? $faker->lexify(str_repeat('?', $size)) : $faker->word(),
'object' => fn () => [],
'file' => fn () => UploadedFile::fake()->create('test.jpg')->size($size ?: 10),
];
return $fakeFactoriesByType[$baseType] ?? $fakeFactoriesByType['string'];
}
protected function isSupportedTypeInDocBlocks(string $type): bool
{
$types = [
'integer',
'int',
'number',
'float',
'double',
'boolean',
'bool',
'string',
'object',
];
return in_array(str_replace('[]', '', $type), $types);
}
/**
* Cast a value to a specified type.
*
* @param mixed $value
* @return mixed
*/
protected function castToType($value, string $type)
{
if ($value === null) {
return null;
}
if ($type === 'array') {
$type = 'string[]';
}
if (Str::endsWith($type, '[]')) {
$baseType = mb_strtolower(mb_substr($type, 0, mb_strlen($type) - 2));
return is_array($value) ? array_map(function ($v) use ($baseType) {
return $this->castToType($v, $baseType);
}, $value) : json_decode($value);
}
if ($type === 'object') {
return is_array($value) ? $value : json_decode($value, true);
}
$casts = [
'integer' => 'intval',
'int' => 'intval',
'float' => 'floatval',
'number' => 'floatval',
'double' => 'floatval',
'boolean' => 'boolval',
'bool' => 'boolval',
];
// First, we handle booleans. We can't use a regular cast,
// because PHP considers string 'false' as true.
if ($value === 'false' && ($type === 'boolean' || $type === 'bool')) {
return false;
}
if (isset($casts[$type])) {
return $casts[$type]($value);
}
// Return the value unchanged if there's no applicable cast
return $value;
}
/**
* Allows users to specify that we shouldn't generate an example for the parameter
* by writing 'No-example'.
*
* @return bool if true, don't generate an example for this
*/
protected function shouldExcludeExample(string $description): bool
{
return mb_strpos($description, ' No-example') !== false;
}
/**
* Allows users to specify an example for the parameter by writing 'Example: the-example',
* to be used in example requests and response calls.
*
* @param string $type The type of the parameter. Used to cast the example provided, if any.
* @return array the description and included example
*/
protected function parseExampleFromParamDescription(string $description, string $type): array
{
$exampleWasSpecified = false;
$example = null;
$enumValues = [];
if (preg_match('/(.*)\bExample:\s*([\s\S]+)\s*/s', $description, $content)) {
$exampleWasSpecified = true;
$description = mb_trim($content[1]);
if ($content[2] === 'null') {
// If we intentionally put null as example we return null as example
$example = null;
} else {
// Examples are parsed as strings by default, we need to cast them properly
$example = $this->castToType($content[2], $type);
}
}
if (preg_match('/(.*)\bEnum:\s*([\s\S]+)\s*/s', $description, $content)) {
$description = mb_trim($content[1]);
$enumValues = array_map(
fn ($value) => $this->castToType(mb_trim($value), $type),
explode(',', mb_rtrim(mb_trim($content[2]), '.'))
);
}
return [$description, $example, $enumValues, $exampleWasSpecified];
}
private function getDummyDataGeneratorBetween(string $type, $min, $max = 90, ?string $fieldName = null): \Closure
{
$hints = [
'name' => $fieldName,
'size' => $this->getFaker()->numberBetween($min, $max),
'min' => $min,
'max' => $max,
];
return $this->getDummyValueGenerator($type, $hints);
}
}

Some files were not shown because too many files have changed in this diff Show More