diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6cda304..ced6e9d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Removed
+# 4.37.2 (30 August 2024)
+## Fixed
+- Stop response fields from overflowing to the dark box zone [#868](https://github.com/knuckleswtf/scribe/pull/868)
+- Don't ignore comments for validator parameters with non string/array (e.g. conditional) rule lists [#880](https://github.com/knuckleswtf/scribe/pull/880)
+- Allow custom output path for static and external_static instead of only static [#884](https://github.com/knuckleswtf/scribe/pull/884)
+
+
+# 4.37.1 (11 July 2024)
+## Fixed
+- Multipart file upload in `elements` theme [#864](https://github.com/knuckleswtf/scribe/pull/864)
+- Properly set multiple responses in OpenAPI spec with the same status code [#863](https://github.com/knuckleswtf/scribe/pull/863)
+
+
+
+# 4.37.0 (17 June 2024)
+## Added
+- Support multiple responses in OpenAPI spec using oneOf [#739](https://github.com/knuckleswtf/scribe/pull/739)
+
+
+# 4.36.0 (27 May 2024)
+## Added
+- Add `afterResponseCall` hook [#847](https://github.com/knuckleswtf/scribe/pull/847)
+
+## Fixed
+- Unescape tryItOutBaseURL [09b49b582](https://github.com/knuckleswtf/scribe/commit/09b49b5829647597825b2cc7162382e926d53f90)
+- Ignore `external.html_attributes` for upgrades [f56a48014](https://github.com/knuckleswtf/scribe/commit/f56a480140d25ada8a441f69db9a6a14b5f0dcd1)
+- Fix missing title and logo in `elements` theme [#844](https://github.com/knuckleswtf/scribe/pull/844)
+
+
+# 4.35.0 (26 March 2024)
+## Modified
+- Allow examples to be shown in response fields [#825](https://github.com/knuckleswtf/scribe/pull/825)
+
+## Fixed
+- Try It Out: send numbers in JSON as float, not strings [#830](https://github.com/knuckleswtf/scribe/pull/830)
+- Fix "No such file or directory" error [#829](https://github.com/knuckleswtf/scribe/pull/829)
+- Fix translating rules with translation engines that don't return arrays [#826](https://github.com/knuckleswtf/scribe/pull/826)
+
+# 4.34.0 (15 March 2024)
+## Added
+- Laravel 11 compatibility [#812](https://github.com/knuckleswtf/scribe/pull/812)
+
+## Modified
+- Instantiate some classes via service container for easier overriding. [#822](https://github.com/knuckleswtf/scribe/pull/822)
+
# 4.33.0 (29 February 2024)
## Fixed
- List enums for array items in OpenAPI spec [#818](https://github.com/knuckleswtf/scribe/pull/818)
diff --git a/camel/Extraction/ResponseField.php b/camel/Extraction/ResponseField.php
index ebec6db6..bedf56a8 100644
--- a/camel/Extraction/ResponseField.php
+++ b/camel/Extraction/ResponseField.php
@@ -22,5 +22,8 @@ class ResponseField extends BaseDTO
/** @var boolean */
public $required;
+ /** @var mixed */
+ public $example;
+
public array $enumValues = [];
}
diff --git a/config/scribe.php b/config/scribe.php
index 17e6d70a..66dbb831 100644
--- a/config/scribe.php
+++ b/config/scribe.php
@@ -241,7 +241,13 @@
Strategies\Responses\UseResponseFileTag::class,
[
Strategies\Responses\ResponseCalls::class,
- ['only' => ['GET *']]
+ [
+ 'only' => ['GET *'],
+ // Disable debug mode when generating response calls to avoid error stack traces in responses
+ 'config' => [
+ 'app.debug' => false,
+ ],
+ ]
]
],
'responseFields' => [
diff --git a/resources/css/theme-default.style.css b/resources/css/theme-default.style.css
index dd7d3aa7..9a4741c1 100644
--- a/resources/css/theme-default.style.css
+++ b/resources/css/theme-default.style.css
@@ -688,6 +688,7 @@ html {
.content>p,
.content>table,
.content>ul,
+.content>div,
.content>form>aside,
.content>form>details,
.content>form>h1,
@@ -893,6 +894,11 @@ html {
text-shadow: 0 1px 2px rgba(0, 0, 0, .4)
}
+.content blockquote pre.sf-dump,
+.content pre pre.sf-dump {
+ width: 100%;
+}
+
.content .annotation {
background-color: #292929;
color: #fff;
@@ -961,20 +967,6 @@ html {
.page-wrapper .lang-selector {
display: none
}
- .content aside,
- .content dl,
- .content h1,
- .content h2,
- .content h3,
- .content h4,
- .content h5,
- .content h6,
- .content ol,
- .content p,
- .content table,
- .content ul {
- margin-right: 0
- }
.content>aside,
.content>details,
.content>dl,
@@ -988,6 +980,7 @@ html {
.content>p,
.content>table,
.content>ul,
+ .content>div,
.content>form>aside,
.content>form>details,
.content>form>h1,
diff --git a/resources/js/tryitout.js b/resources/js/tryitout.js
index a3feb1bd..2a1d2b8a 100644
--- a/resources/js/tryitout.js
+++ b/resources/js/tryitout.js
@@ -139,6 +139,17 @@ function handleResponse(endpointId, response, status, headers) {
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 {
@@ -146,11 +157,12 @@ function handleResponse(endpointId, response, status, headers) {
if (jsonParsed !== null) {
isJson = true;
response = JSON.stringify(jsonParsed, null, 4);
+ responseContentEl.textContent = response;
}
} catch (e) {
}
- responseContentEl.textContent = response === '' ? responseContentEl.dataset.emptyResponseText : response;
+
isJson && window.hljs.highlightElement(responseContentEl);
const statusEl = document.querySelector('#execution-response-status-' + endpointId);
statusEl.textContent = ` (${status})`;
@@ -194,6 +206,11 @@ async function executeTryOut(endpointId, form) {
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;
diff --git a/resources/views/external/elements.blade.php b/resources/views/external/elements.blade.php
index db48c4ff..bff12b3a 100644
--- a/resources/views/external/elements.blade.php
+++ b/resources/views/external/elements.blade.php
@@ -4,10 +4,15 @@
- Elements in HTML
+ {!! $metadata['title'] !!}
+
@@ -20,7 +25,9 @@
router="hash"
layout="sidebar"
hideTryIt="{!! ($tryItOut['enabled'] ?? true) ? '' : 'true'!!}"
+@if(!empty($metadata['logo']))
logo="{!! $metadata['logo'] !!}"
+@endif
/>
diff --git a/resources/views/themes/default/index.blade.php b/resources/views/themes/default/index.blade.php
index d0f46344..672a5074 100644
--- a/resources/views/themes/default/index.blade.php
+++ b/resources/views/themes/default/index.blade.php
@@ -33,9 +33,9 @@
@if($tryItOut['enabled'] ?? true)
@endif
diff --git a/resources/views/themes/elements/components/field-details.blade.php b/resources/views/themes/elements/components/field-details.blade.php
index b2682fc0..d8c57cf4 100644
--- a/resources/views/themes/elements/components/field-details.blade.php
+++ b/resources/views/themes/elements/components/field-details.blade.php
@@ -49,12 +49,12 @@ class="svg-inline--fa fa-chevron-right fa-fw fa-sm sl-icon" role="img"
@endif
@endif
- @if(!$hasChildren && !is_null($example) && $example != '')
+ @if(!$hasChildren && !is_null($example) && $example !== '')
Example:
- {{ is_array($example) ? json_encode($example) : $example }}
+ {{ is_array($example) || is_bool($example) ? json_encode($example) : $example }}
diff --git a/resources/views/themes/elements/index.blade.php b/resources/views/themes/elements/index.blade.php
index bc1ba060..b921e15c 100644
--- a/resources/views/themes/elements/index.blade.php
+++ b/resources/views/themes/elements/index.blade.php
@@ -111,6 +111,11 @@ function tryItOut(btnElement) {
});
}
+ // 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;
@@ -132,6 +137,9 @@ function tryItOut(btnElement) {
}
} catch (e) {}
+ // Replace HTML entities
+ responseContent = responseContent.replace(/[<>&]/g, (i) => '' + i.charCodeAt(0) + ';');
+
contentEl.innerHTML = responseContent;
isJson && window.hljs.highlightElement(contentEl);
})
diff --git a/src/Commands/GenerateDocumentation.php b/src/Commands/GenerateDocumentation.php
index bddc5e33..ab48547b 100644
--- a/src/Commands/GenerateDocumentation.php
+++ b/src/Commands/GenerateDocumentation.php
@@ -63,7 +63,7 @@ public function handle(RouteMatcherInterface $routeMatcher, GroupedEndpointsFact
$this->writeExampleCustomEndpoint();
}
- $writer = new Writer($this->docConfig, $this->paths);
+ $writer = app(Writer::class, ['config' => $this->docConfig, 'paths' => $this->paths]);
$writer->writeDocs($groupedEndpoints);
$this->upgradeConfigFileIfNeeded();
@@ -178,7 +178,7 @@ protected function upgradeConfigFileIfNeeded(): void
)
->dontTouch(
'routes', 'example_languages', 'database_connections_to_transact', 'strategies', 'laravel.middleware',
- 'postman.overrides', 'openapi.overrides', 'groups', 'examples.models_source'
+ 'postman.overrides', 'openapi.overrides', 'groups', 'examples.models_source', 'external.html_attributes'
);
$changes = $upgrader->dryRun();
if (!empty($changes)) {
diff --git a/src/Commands/Upgrade.php b/src/Commands/Upgrade.php
index dc53ffe5..b25d8505 100644
--- a/src/Commands/Upgrade.php
+++ b/src/Commands/Upgrade.php
@@ -43,7 +43,7 @@ public function handle(): void
$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')
+ 'example_languages', 'database_connections_to_transact', 'strategies', 'examples.models_source', 'external.html_attributes')
->move('default_group', 'groups.default')
->move('faker_seed', 'examples.faker_seed');
diff --git a/src/Extracting/DatabaseTransactionHelpers.php b/src/Extracting/DatabaseTransactionHelpers.php
index 044c133c..c9b860c0 100644
--- a/src/Extracting/DatabaseTransactionHelpers.php
+++ b/src/Extracting/DatabaseTransactionHelpers.php
@@ -15,9 +15,9 @@ private function connectionsToTransact()
private function startDbTransaction()
{
- $database = app('db');
-
foreach ($this->connectionsToTransact() as $connection) {
+ $database ??= app('db');
+
$driver = $database->connection($connection);
if (self::driverSupportsTransactions($driver)) {
@@ -30,7 +30,6 @@ private function startDbTransaction()
" If you aren't using this database, remove it from the `database_connections_to_transact` config array."
);
}
- continue;
} else {
$driverClassName = get_class($driver);
throw DatabaseTransactionsNotSupported::create($connection, $driverClassName);
@@ -43,9 +42,9 @@ private function startDbTransaction()
*/
private function endDbTransaction()
{
- $database = app('db');
-
foreach ($this->connectionsToTransact() as $connection) {
+ $database ??= app('db');
+
$driver = $database->connection($connection);
try {
$driver->rollback();
diff --git a/src/Extracting/Extractor.php b/src/Extracting/Extractor.php
index 1c8f6d25..fd56d469 100644
--- a/src/Extracting/Extractor.php
+++ b/src/Extracting/Extractor.php
@@ -215,23 +215,24 @@ protected function iterateThroughStrategies(
}
$settings = self::transformOldRouteRulesIntoNewSettings($stage, $rulesToApply, $strategyClass, $settings);
- $routesToSkip = $settings["except"] ?? [];
- $routesToInclude = $settings["only"] ?? [];
-
- if (!empty($routesToSkip)) {
- if (RoutePatternMatcher::matches($endpointData->route, $routesToSkip)) {
- continue;
- }
- } elseif (!empty($routesToInclude)) {
- if (!RoutePatternMatcher::matches($endpointData->route, $routesToInclude)) {
- continue;
- }
- }
} else {
$strategyClass = $strategyClassOrTuple;
$settings = self::transformOldRouteRulesIntoNewSettings($stage, $rulesToApply, $strategyClass);
}
+ $routesToSkip = $settings["except"] ?? [];
+ $routesToInclude = $settings["only"] ?? [];
+
+ if (!empty($routesToSkip)) {
+ if (RoutePatternMatcher::matches($endpointData->route, $routesToSkip)) {
+ continue;
+ }
+ } elseif (!empty($routesToInclude)) {
+ if (!RoutePatternMatcher::matches($endpointData->route, $routesToInclude)) {
+ continue;
+ }
+ }
+
$strategy = new $strategyClass($this->config);
$results = $strategy($endpointData, $settings);
if (is_array($results)) {
diff --git a/src/Extracting/ParsesValidationRules.php b/src/Extracting/ParsesValidationRules.php
index bec00aa1..0dd9038c 100644
--- a/src/Extracting/ParsesValidationRules.php
+++ b/src/Extracting/ParsesValidationRules.php
@@ -531,7 +531,9 @@ protected function parseRule($rule, array &$parameterData, bool $independentOnly
case 'different':
$parameterData['description'] .= " The value and {$arguments[0]}
must be different.";
break;
-
+ case 'exists':
+ $parameterData['description'] .= " The {$arguments[1]}
of an existing record in the {$arguments[0]} table.";
+ break;
default:
// Other rules not supported
break;
@@ -636,8 +638,11 @@ public function convertGenericArrayType(array $parameters): array
// 2. If `users.` exists, `users` is an `object`
// 3. Otherwise, default to `object`
// Important: We're iterating in reverse, to ensure we set child items before parent items
- // (assuming the user specified parents first, which is the more common thing)
- if ($childKey = Arr::first($allKeys, fn($key) => Str::startsWith($key, "$name.*"))) {
+ // (assuming the user specified parents first, which is the more common thing)y
+ if(Arr::first($allKeys, fn($key) => Str::startsWith($key, "$name.*."))) {
+ $details['type'] = 'object[]';
+ unset($details['setter']);
+ } else if ($childKey = Arr::first($allKeys, fn($key) => Str::startsWith($key, "$name.*"))) {
$childType = ($converted[$childKey] ?? $parameters[$childKey])['type'];
$details['type'] = "{$childType}[]";
} else { // `array` types default to `object` if no subtype is specified
@@ -778,11 +783,21 @@ protected function getDescription(string $rule, array $arguments = [], $baseType
return "Must match the regex {$arguments[':regex']}.";
}
- $description = trans("validation.{$rule}");
- // For rules that can apply to multiple types (eg 'max' rule), Laravel returns an array of possible messages
+ $translationString = "validation.{$rule}";
+ $description = trans($translationString);
+
+ // For rules that can apply to multiple types (eg 'max' rule), There is an array of possible messages
// 'numeric' => 'The :attribute must not be greater than :max'
// 'file' => 'The :attribute must have a size less than :max kilobytes'
- if (is_array($description)) {
+ // Depending on the translation engine, trans may return the array, or it will fail to translate the string
+ // and will need to be called with the baseType appended.
+ if ($description === $translationString) {
+ $translationString = "{$translationString}.{$baseType}";
+ $translated = trans($translationString);
+ if ($translated !== $translationString) {
+ $description = $translated;
+ }
+ } elseif (is_array($description)) {
$description = $description[$baseType];
}
diff --git a/src/Extracting/Strategies/GetFromInlineValidatorBase.php b/src/Extracting/Strategies/GetFromInlineValidatorBase.php
index 5757b55a..e52640f5 100644
--- a/src/Extracting/Strategies/GetFromInlineValidatorBase.php
+++ b/src/Extracting/Strategies/GetFromInlineValidatorBase.php
@@ -81,7 +81,6 @@ public function lookForInlineValidationRules(ClassMethod $methodAst): array
if ($arrayItem->value instanceof Node\Scalar\String_) {
$rulesList[] = $arrayItem->value->value;
}
-
// Try to extract Enum rule
else if (
function_exists('enum_exists') &&
@@ -97,7 +96,6 @@ enum_exists($enum) && method_exists($enum, 'tryFrom')
$rules[$paramName] = join('|', $rulesList);
} else {
$rules[$paramName] = [];
- continue;
}
$dataFromComment = [];
diff --git a/src/Extracting/Strategies/GetParamsFromAttributeStrategy.php b/src/Extracting/Strategies/GetParamsFromAttributeStrategy.php
index 42ff07cc..d6e1264d 100644
--- a/src/Extracting/Strategies/GetParamsFromAttributeStrategy.php
+++ b/src/Extracting/Strategies/GetParamsFromAttributeStrategy.php
@@ -34,7 +34,7 @@ protected function normalizeParameterData(array $data): array
'name' => $data['name'],
'enumValues' => $data['enumValues'],
]);
- } else if ($data['example'] == 'No-example' || $data['example'] == 'No-example.') {
+ } else if ($data['example'] === 'No-example' || $data['example'] === 'No-example.') {
$data['example'] = null;
}
diff --git a/src/Extracting/Strategies/Responses/ResponseCalls.php b/src/Extracting/Strategies/Responses/ResponseCalls.php
index 3b9ff2c5..fdd640f7 100644
--- a/src/Extracting/Strategies/Responses/ResponseCalls.php
+++ b/src/Extracting/Strategies/Responses/ResponseCalls.php
@@ -2,8 +2,6 @@
namespace Knuckles\Scribe\Extracting\Strategies\Responses;
-use Illuminate\Support\Facades\Config;
-use Knuckles\Camel\Extraction\ExtractedEndpointData;
use Dingo\Api\Dispatcher;
use Dingo\Api\Routing\Route as DingoRoute;
use Exception;
@@ -12,7 +10,9 @@
use Illuminate\Http\Response;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route;
+use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
+use Knuckles\Camel\Extraction\ExtractedEndpointData;
use Knuckles\Scribe\Extracting\DatabaseTransactionHelpers;
use Knuckles\Scribe\Extracting\ParamHelpers;
use Knuckles\Scribe\Extracting\Strategies\Strategy;
@@ -89,6 +89,9 @@ public function makeResponseCall(ExtractedEndpointData $endpointData, array $set
try {
$response = $this->makeApiCall($request, $endpointData->route);
+
+ $this->runPostRequestHook($request, $endpointData, $response);
+
$response = [
[
'status' => $response->getStatusCode(),
@@ -170,6 +173,13 @@ protected function runPreRequestHook(Request $request, ExtractedEndpointData $en
}
}
+ protected function runPostRequestHook(Request $request, ExtractedEndpointData $endpointData, mixed $response): void
+ {
+ if (is_callable(Globals::$__afterResponseCall)) {
+ call_user_func_array(Globals::$__afterResponseCall, [$request, $endpointData, $response]);
+ }
+ }
+
private function setLaravelConfigs(array $config)
{
if (empty($config)) {
diff --git a/src/Scribe.php b/src/Scribe.php
index d83371d8..074ff08e 100644
--- a/src/Scribe.php
+++ b/src/Scribe.php
@@ -9,7 +9,7 @@
class Scribe
{
- public const VERSION = '4.33.0';
+ public const VERSION = '4.37.2';
/**
* Specify a callback that will be executed just before a response call is made
@@ -22,6 +22,17 @@ public static function beforeResponseCall(callable $callable)
Globals::$__beforeResponseCall = $callable;
}
+ /**
+ * Specify a callback that will be executed just after a response call is done
+ * (allowing to modify the response).
+ *
+ * @param callable(Request, ExtractedEndpointData, mixed): mixed $callable
+ */
+ public static function afterResponseCall(callable $callable)
+ {
+ Globals::$__afterResponseCall = $callable;
+ }
+
/**
* Specify a callback that will be executed just before the generate command is executed
*
diff --git a/src/ScribeServiceProvider.php b/src/ScribeServiceProvider.php
index 3b1d404c..0c5fce5f 100644
--- a/src/ScribeServiceProvider.php
+++ b/src/ScribeServiceProvider.php
@@ -120,7 +120,7 @@ protected function registerCommands(): void
public function loadCustomTranslationLayer(): void
{
$this->app->extend('translation.loader', function ($defaultFileLoader) {
- return new CustomTranslationsLoader($defaultFileLoader);
+ return app(CustomTranslationsLoader::class, ['loader' => $defaultFileLoader]);
});
$this->app->forgetInstance('translator');
self::$customTranslationLayerLoaded = true;
diff --git a/src/Tools/Globals.php b/src/Tools/Globals.php
index 28a661d7..8f7657df 100644
--- a/src/Tools/Globals.php
+++ b/src/Tools/Globals.php
@@ -12,6 +12,8 @@ class Globals
public static $__beforeResponseCall;
+ public static $__afterResponseCall;
+
public static $__bootstrap;
public static $__afterGenerating;
diff --git a/src/Tools/Utils.php b/src/Tools/Utils.php
index d16eaf8a..114de014 100644
--- a/src/Tools/Utils.php
+++ b/src/Tools/Utils.php
@@ -9,6 +9,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Routing\Route;
+use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Knuckles\Scribe\Exceptions\CouldntFindFactory;
use Knuckles\Scribe\Exceptions\CouldntGetRouteDetails;
@@ -191,6 +192,11 @@ public static function copyDirectory(string $src, string $dest): void
}
}
+ public static function makeDirectoryRecursive(string $dir): void
+ {
+ File::isDirectory($dir) || File::makeDirectory($dir, 0777, true, true);
+ }
+
public static function deleteFilesMatching(string $dir, callable $condition): void
{
if (class_exists(LocalFilesystemAdapter::class)) {
@@ -364,7 +370,7 @@ public static function trans(string $key, array $replace = [])
{
// We only load our custom translation layer if we really need it
if (!ScribeServiceProvider::$customTranslationLayerLoaded) {
- (new ScribeServiceProvider(app()))->loadCustomTranslationLayer();
+ app(ScribeServiceProvider::class, ['app' => app()])->loadCustomTranslationLayer();
}
$translation = trans($key, $replace);
diff --git a/src/Writing/HtmlWriter.php b/src/Writing/HtmlWriter.php
index b40b8522..78fa4c82 100644
--- a/src/Writing/HtmlWriter.php
+++ b/src/Writing/HtmlWriter.php
@@ -30,7 +30,7 @@ public function __construct(DocumentationConfig $config = null)
// If they're using the default static path,
// then use '../docs/{asset}', so assets can work via Laravel app or via index.html
$this->assetPathPrefix = '../docs/';
- if ($this->config->get('type') == 'static'
+ if (in_array($this->config->get('type'), ['static', 'external_static'])
&& rtrim($this->config->get('static.output_path', ''), '/') != 'public/docs'
) {
$this->assetPathPrefix = './';
diff --git a/src/Writing/OpenAPISpecWriter.php b/src/Writing/OpenAPISpecWriter.php
index 08adc858..bed0faee 100644
--- a/src/Writing/OpenAPISpecWriter.php
+++ b/src/Writing/OpenAPISpecWriter.php
@@ -269,7 +269,33 @@ protected function generateEndpointResponsesSpec(OutputEndpointData $endpoint)
$responses[204] = [
'description' => $this->getResponseDescription($response),
];
+ } elseif (isset($responses[$response->status])) {
+ // If we already have a response for this status code and content type,
+ // we change to a `oneOf` which includes all the responses
+ $content = $this->generateResponseContentSpec($response->content, $endpoint);
+ $contentType = array_keys($content)[0];
+ if (isset($responses[$response->status]['content'][$contentType])) {
+ $newResponseExample = array_replace([
+ 'description' => $this->getResponseDescription($response),
+ ], $content[$contentType]['schema']);
+
+ // If we've already created the oneOf object, add this response
+ if (isset($responses[$response->status]['content'][$contentType]['schema']['oneOf'])) {
+ $responses[$response->status]['content'][$contentType]['schema']['oneOf'][] = $newResponseExample;
+ } else {
+ // Create the oneOf object
+ $existingResponseExample = array_replace([
+ 'description' => $responses[$response->status]['description'],
+ ], $responses[$response->status]['content'][$contentType]['schema']);
+
+ $responses[$response->status]['description'] = '';
+ $responses[$response->status]['content'][$contentType]['schema'] = [
+ 'oneOf' => [$existingResponseExample, $newResponseExample]
+ ];
+ }
+ }
} else {
+ // Store as the response for this status
$responses[$response->status] = [
'description' => $this->getResponseDescription($response),
'content' => $this->generateResponseContentSpec($response->content, $endpoint),
diff --git a/src/Writing/PostmanCollectionWriter.php b/src/Writing/PostmanCollectionWriter.php
index a115f465..29be7765 100644
--- a/src/Writing/PostmanCollectionWriter.php
+++ b/src/Writing/PostmanCollectionWriter.php
@@ -293,7 +293,7 @@ protected function generateUrlObject(OutputEndpointData $endpointData): array
// See https://www.php.net/manual/en/function.parse-str.php
$query[] = [
'key' => "{$name}[$index]",
- 'value' => $value,
+ 'value' => is_string($value) ? $value : strval($value),
'description' => strip_tags($parameterData->description),
// Default query params to disabled if they aren't required and have empty values
'disabled' => !$parameterData->required && empty($parameterData->example),
diff --git a/src/Writing/Writer.php b/src/Writing/Writer.php
index f0ce56d7..69db61cc 100644
--- a/src/Writing/Writer.php
+++ b/src/Writing/Writer.php
@@ -3,7 +3,6 @@
namespace Knuckles\Scribe\Writing;
use Illuminate\Support\Facades\Storage;
-use Illuminate\Support\Str;
use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
use Knuckles\Scribe\Tools\DocumentationConfig;
use Knuckles\Scribe\Tools\Globals;
@@ -13,12 +12,12 @@
class Writer
{
- private bool $isStatic;
- private bool $isExternal;
+ protected bool $isStatic;
+ protected bool $isExternal;
- private ?string $staticTypeOutputPath;
+ protected ?string $staticTypeOutputPath;
- private ?string $laravelTypeOutputPath;
+ protected ?string $laravelTypeOutputPath;
protected array $generatedFiles = [
'postman' => null,
'openapi' => null,
@@ -31,7 +30,7 @@ class Writer
],
];
- private string $laravelAssetsPath;
+ protected string $laravelAssetsPath;
public function __construct(protected DocumentationConfig $config, public PathConfig $paths)
{
@@ -96,6 +95,7 @@ protected function writeOpenAPISpec(array $parsedRoutes): void
$spec = $this->generateOpenAPISpec($parsedRoutes);
if ($this->isStatic) {
+ Utils::makeDirectoryRecursive($this->staticTypeOutputPath);
$specPath = "{$this->staticTypeOutputPath}/openapi.yaml";
file_put_contents($specPath, $spec);
} else {
diff --git a/tests/Unit/ExtractorTest.php b/tests/Unit/ExtractorTest.php
index 6e355041..6f74e6ec 100644
--- a/tests/Unit/ExtractorTest.php
+++ b/tests/Unit/ExtractorTest.php
@@ -7,11 +7,12 @@
use Knuckles\Camel\Extraction\Parameter;
use Knuckles\Scribe\Extracting\Extractor;
use Knuckles\Scribe\Extracting\Strategies;
+use Knuckles\Scribe\Tests\BaseLaravelTest;
use Knuckles\Scribe\Tests\BaseUnitTest;
use Knuckles\Scribe\Tests\Fixtures\TestController;
use Knuckles\Scribe\Tools\DocumentationConfig;
-class ExtractorTest extends BaseUnitTest
+class ExtractorTest extends BaseLaravelTest
{
protected Extractor $extractor;
@@ -52,7 +53,7 @@ public function setUp(): void
{
parent::setUp();
- $this->extractor = new Extractor(new DocumentationConfig($this->config));
+ $this->extractor = $this->makeExtractor($this->config);
}
/** @test */
@@ -181,6 +182,89 @@ public function can_parse_route_methods()
$this->assertEquals(['DELETE'], $parsed->httpMethods);
}
+ /** @test */
+ public function invokes_strategy_based_on_deprecated_route_apply_rules()
+ {
+ $config = $this->config;
+ $config['strategies']['responses'] = [Strategies\Responses\ResponseCalls::class];
+
+ $extractor = $this->makeExtractor($config);
+ $route = $this->createRoute('GET', '/get', 'shouldFetchRouteResponse');
+ $parsed = $extractor->processRoute($route, ['response_calls' => ['methods' => ['POST']]]);
+ $this->assertEmpty($parsed->responses);
+
+ $parsed = $extractor->processRoute($route, ['response_calls' => ['methods' => ['GET']]]);
+ $this->assertNotEmpty($parsed->responses);
+ }
+
+ /** @test */
+ public function invokes_strategy_based_on_new_strategy_configs()
+ {
+ $route = $this->createRoute('GET', '/get', 'shouldFetchRouteResponse');
+ $config = $this->config;
+ $config['strategies']['responses'] = [
+ [
+ Strategies\Responses\ResponseCalls::class,
+ ['only' => 'POST *']
+ ]
+ ];
+ $extractor = $this->makeExtractor($config);
+
+ $parsed = $extractor->processRoute($route);
+ $this->assertEmpty($parsed->responses);
+
+ $config['strategies']['responses'] = [
+ [
+ Strategies\Responses\ResponseCalls::class,
+ ['only' => 'GET *']
+ ]
+ ];
+ $extractor = $this->makeExtractor($config);
+ $parsed = $extractor->processRoute($route);
+ $this->assertNotEmpty($parsed->responses);
+ }
+
+ /** @test */
+ public function adds_override_for_headers_based_on_deprecated_route_apply_rules()
+ {
+ $config = $this->config;
+ $config['strategies']['headers'] = [Strategies\Headers\GetFromRouteRules::class];
+
+ $extractor = $this->makeExtractor($config);
+ $route = $this->createRoute('GET', '/get', 'dummy');
+ $parsed = $extractor->processRoute($route, ['headers' => ['content-type' => 'application/json+vnd']]);
+ $this->assertArraySubset($parsed->headers, ['content-type' => 'application/json+vnd']);
+
+ $parsed = $extractor->processRoute($route);
+ $this->assertEmpty($parsed->headers);
+ }
+
+ /** @test */
+ public function adds_override_for_headers_based_on_strategy_configs()
+ {
+ $route = $this->createRoute('GET', '/get', 'dummy');
+ $config = $this->config;
+
+ $config['strategies']['headers'] = [Strategies\Headers\GetFromHeaderAttribute::class];
+ $extractor = $this->makeExtractor($config);
+ $parsed = $extractor->processRoute($route);
+ $this->assertEmpty($parsed->headers);
+
+ $headers = [
+ 'accept' => 'application/json',
+ 'Content-Type' => 'application/json+vnd',
+ ];
+ $config['strategies']['headers'] = [
+ Strategies\Headers\GetFromHeaderAttribute::class,
+ [
+ 'override', $headers
+ ],
+ ];
+ $extractor = $this->makeExtractor($config);
+ $parsed = $extractor->processRoute($route);
+ $this->assertArraySubset($parsed->headers, $headers);
+ }
+
/**
* @test
* @dataProvider authRules
@@ -188,7 +272,7 @@ public function can_parse_route_methods()
public function adds_appropriate_field_based_on_configured_auth_type($config, $expected)
{
$route = $this->createRouteOldSyntax('POST', '/withAuthenticatedTag', 'withAuthenticatedTag');
- $generator = new Extractor(new DocumentationConfig(array_merge($this->config, $config)));
+ $generator = $this->makeExtractor(array_merge($this->config, $config));
$parsed = $generator->processRoute($route, [])->toArray();
$this->assertNotNull($parsed[$expected['where']][$expected['name']]);
$this->assertEquals($expected['where'], $parsed['auth'][0]);
@@ -210,7 +294,7 @@ public function generates_consistent_examples_when_faker_seed_is_set()
// Examples should have different values
$this->assertNotEquals(1, count($results));
- $generator = new Extractor(new DocumentationConfig($this->config + ['examples' => ['faker_seed' => 12345]]));
+ $generator = $this->makeExtractor($this->config + ['examples' => ['faker_seed' => 12345]]);
$results = [];
$results[$generator->processRoute($route)->cleanBodyParameters[$paramName]] = true;
$results[$generator->processRoute($route)->cleanBodyParameters[$paramName]] = true;
@@ -374,6 +458,11 @@ public static function authRules()
],
];
}
+
+ protected function makeExtractor(mixed $config): Extractor
+ {
+ return new Extractor(new DocumentationConfig($config));
+ }
}
diff --git a/tests/Unit/OpenAPISpecWriterTest.php b/tests/Unit/OpenAPISpecWriterTest.php
index 0a1660c3..cb066b86 100644
--- a/tests/Unit/OpenAPISpecWriterTest.php
+++ b/tests/Unit/OpenAPISpecWriterTest.php
@@ -451,10 +451,10 @@ public function adds_responses_correctly_as_responses_on_operation_object()
'description' => 'This response parameter is required.',
'required' => true,
],
- 'sub level 0.sub level 1 key 3.sub level 2 key 1'=> [
+ 'sub level 0.sub level 1 key 3.sub level 2 key 1' => [
'description' => 'This is a description of a nested object',
],
- 'sub level 0.sub level 1 key 3.sub level 2 key 3 required'=> [
+ 'sub level 0.sub level 1 key 3.sub level 2 key 3 required' => [
'description' => 'This is a description of a required nested object',
'required' => true,
],
@@ -584,6 +584,179 @@ public function adds_responses_correctly_as_responses_on_operation_object()
], $results['paths']['/path2']['put']['responses']);
}
+ /** @test */
+ public function adds_multiple_responses_correctly_using_oneOf()
+ {
+ $endpointData1 = $this->createMockEndpointData([
+ 'httpMethods' => ['POST'],
+ 'uri' => '/path1',
+ 'responses' => [
+ [
+ 'status' => 201,
+ 'description' => 'This one',
+ 'content' => '{"this": "one"}',
+ ],
+ [
+ 'status' => 201,
+ 'description' => 'No, that one.',
+ 'content' => '{"that": "one"}',
+ ],
+ [
+ 'status' => 200,
+ 'description' => 'A separate one',
+ 'content' => '{"the other": "one"}',
+ ],
+ ],
+ ]);
+ $groups = [$this->createGroup([$endpointData1])];
+
+ $results = $this->generate($groups);
+
+ $this->assertArraySubset([
+ '200' => [
+ 'description' => 'A separate one',
+ 'content' => [
+ 'application/json' => [
+ 'schema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'the other' => [
+ 'example' => "one",
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ '201' => [
+ 'description' => '',
+ 'content' => [
+ 'application/json' => [
+ 'schema' => [
+ 'oneOf' => [
+ [
+ 'type' => 'object',
+ 'description' => 'This one',
+ 'properties' => [
+ 'this' => [
+ 'example' => "one",
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ [
+ 'type' => 'object',
+ 'description' => 'No, that one.',
+ 'properties' => [
+ 'that' => [
+ 'example' => "one",
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ], $results['paths']['/path1']['post']['responses']);
+ }
+
+ /** @test */
+ public function adds_more_than_two_answers_correctly_using_oneOf()
+ {
+ $endpointData1 = $this->createMockEndpointData([
+ 'httpMethods' => ['POST'],
+ 'uri' => '/path1',
+ 'responses' => [
+ [
+ 'status' => 201,
+ 'description' => 'This one',
+ 'content' => '{"this": "one"}',
+ ],
+ [
+ 'status' => 201,
+ 'description' => 'No, that one.',
+ 'content' => '{"that": "one"}',
+ ],
+ [
+ 'status' => 201,
+ 'description' => 'No, another one.',
+ 'content' => '{"another": "one"}',
+ ],
+ [
+ 'status' => 200,
+ 'description' => 'A separate one',
+ 'content' => '{"the other": "one"}',
+ ],
+ ],
+ ]);
+ $groups = [$this->createGroup([$endpointData1])];
+
+ $results = $this->generate($groups);
+
+ $this->assertArraySubset([
+ '200' => [
+ 'description' => 'A separate one',
+ 'content' => [
+ 'application/json' => [
+ 'schema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'the other' => [
+ 'example' => "one",
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ '201' => [
+ 'description' => '',
+ 'content' => [
+ 'application/json' => [
+ 'schema' => [
+ 'oneOf' => [
+ [
+ 'type' => 'object',
+ 'description' => 'This one',
+ 'properties' => [
+ 'this' => [
+ 'example' => "one",
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ [
+ 'type' => 'object',
+ 'description' => 'No, that one.',
+ 'properties' => [
+ 'that' => [
+ 'example' => "one",
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ [
+ 'type' => 'object',
+ 'description' => 'No, another one.',
+ 'properties' => [
+ 'another' => [
+ 'example' => "one",
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ], $results['paths']['/path1']['post']['responses']);
+ }
+
protected function createMockEndpointData(array $custom = []): OutputEndpointData
{
$faker = Factory::create();
diff --git a/tests/Unit/UtilsTest.php b/tests/Unit/UtilsTest.php
new file mode 100644
index 00000000..a5eab141
--- /dev/null
+++ b/tests/Unit/UtilsTest.php
@@ -0,0 +1,23 @@
+assertDirectoryExists($dir); // Directory exists
+
+ if (rmdir($dir)) { // Remove the directory
+ dump("Directory deleted successfully: $dir");
+ } else { // If deletion fails, you can handle the error as needed
+ dump("Failed to delete directory: $dir");
+ }
+ }
+}
diff --git a/tests/Unit/ValidationRuleParsingTest.php b/tests/Unit/ValidationRuleParsingTest.php
index 95765404..5a877ac9 100644
--- a/tests/Unit/ValidationRuleParsingTest.php
+++ b/tests/Unit/ValidationRuleParsingTest.php
@@ -3,7 +3,9 @@
namespace Knuckles\Scribe\Tests\Unit;
use Illuminate\Foundation\Application;
+use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
+use Illuminate\Translation\Translator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Knuckles\Scribe\Extracting\ParsesValidationRules;
@@ -39,6 +41,11 @@ public function parse($validationRules, $customParameterData = []): array
*/
public function can_parse_supported_rules(array $ruleset, array $customInfo, array $expected)
{
+ // Needed for `exists` rule
+ Schema::create('users', function ($table) {
+ $table->id();
+ });
+
$results = $this->strategy->parse($ruleset, $customInfo);
$parameterName = array_keys($ruleset)[0];
@@ -48,7 +55,9 @@ public function can_parse_supported_rules(array $ruleset, array $customInfo, arr
$this->assertEquals($expected['type'], $results[$parameterName]['type']);
}
- // Validate that the generated values actually pass validation
+ // Validate that the generated values actually pass validation (for rules where we can generate some data)
+ if (is_string($ruleset[$parameterName]) && str_contains($ruleset[$parameterName], "exists")) return;
+
$exampleData = [$parameterName => $results[$parameterName]['example']];
$validator = Validator::make($exampleData, $ruleset);
try {
@@ -438,6 +447,13 @@ public static function supportedRules()
'description' => 'Must be accepted.',
],
];
+ yield 'exists' => [
+ ['exists_param' => 'exists:users,id'],
+ [],
+ [
+ 'description' => 'The id
of an existing record in the users table.',
+ ],
+ ];
yield 'unsupported' => [
['unsupported_param' => [new DummyValidationRule, 'bail']],
['unsupported_param' => ['description' => $description]],
@@ -597,6 +613,32 @@ public function can_parse_enum_rules()
array_map(fn ($case) => $case->value, Fixtures\TestStringBackedEnum::cases())
));
}
+
+ /** @test */
+ public function can_translate_validation_rules_with_types_with_translator_without_array_support()
+ {
+ // Single line DocComment
+ $ruleset = [
+ 'nested' => [
+ 'string', 'max:20',
+ ],
+ ];
+
+ $results = $this->strategy->parse($ruleset);
+
+ $this->assertEquals('Must not be greater than 20 characters.', $results['nested']['description']);
+
+ $this->app->extend('translator', function ($command, $app) {
+ $loader = $app['translation.loader'];
+ $locale = $app['config']['app.locale'];
+ return new DummyTranslator($loader, $locale);
+ });
+
+ $results = $this->strategy->parse($ruleset);
+
+ $this->assertEquals('successfully translated by concatenated string.', $results['nested']['description']);
+
+ }
}
class DummyValidationRule implements \Illuminate\Contracts\Validation\Rule
@@ -673,3 +715,15 @@ public static function docs()
}
}
}
+
+class DummyTranslator extends Translator
+{
+ public function get($key, array $replace = [], $locale = null, $fallback = true)
+ {
+ if ($key === 'validation.max.string') {
+ return 'successfully translated by concatenated string';
+ }
+
+ return $key;
+ }
+}